Skip to main content
โšก Calmops

Golang Connect to Meilisearch

Meilisearch is a fast, open-source search engine written in Rust that provides instant typo-tolerant full-text search through a RESTful API. Pairing it with Go gives you a production-grade search layer for any application โ€” from e-commerce product lookup to documentation search to log exploration. This guide covers everything from basic client setup to advanced hybrid search, index management, and production hardening.

What is Meilisearch?

Meilisearch indexes JSON documents and returns ranked search results in milliseconds. Its standout features include typo tolerance by default, customizable ranking rules, faceted search, filters, and native vector (hybrid) search support in v1.13+. It can run self-hosted or as a managed cloud service, and the official Go client wraps the entire REST API with a clean, idiomatic interface.

Prerequisites

  • Go 1.21 or later (for slog, context improvements, and generics support).
  • A running Meilisearch instance โ€” local (v1.13+) or cloud.
  • The Go client: go get github.com/meilisearch/meilisearch-go@latest.

Project Setup and Dependency Management

mkdir meilisearch-go-app
cd meilisearch-go-app
go mod init meilisearch-go-app
go get github.com/meilisearch/meilisearch-go@latest

The official client handles HTTP transport, request serialization, and task polling. Pin your dependency with go mod tidy after each update to avoid surprises during deployments.

Client Initialization

Basic Client

package main

import (
    "context"
    "fmt"
    "log"
    "log/slog"
    "time"

    "github.com/meilisearch/meilisearch-go"
)

func main() {
    client := meilisearch.New("http://localhost:7700", meilisearch.WithAPIKey("master_key"))

    health, err := client.Health()
    if err != nil {
        log.Fatalf("meilisearch unreachable: %v", err)
    }
    fmt.Printf("Meilisearch status: %s | version: %s\n", health.Status, health.Version)
}

Client With Multiple Configuration Options

func newSearchClient() meilisearch.ServiceManager {
    cfg := &meilisearch.ClientConfig{
        Host:    "https://search.example.com",
        APIKey:  "prod_key_abc123",
        Timeout: 30 * time.Second,
    }
    return meilisearch.NewClient(*cfg)
}

For advanced needs โ€” custom TLS, connection pooling, or retry โ€” wrap the underlying HTTP client:

func newHardenedClient() meilisearch.ServiceManager {
    transport := &http.Transport{
        MaxIdleConns:        100,
        IdleConnTimeout:     90 * time.Second,
        TLSClientConfig:     &tls.Config{MinVersion: tls.VersionTLS13},
        DisableCompression:  false,
    }
    httpClient := &http.Client{
        Transport: transport,
        Timeout:   60 * time.Second,
    }
    cfg := &meilisearch.ClientConfig{
        Host:       "https://search.example.com",
        APIKey:     "prod_key_abc123",
        HttpClient: httpClient,
    }
    return meilisearch.NewClient(*cfg)
}

CRUD Operations

Struct and Document Preparation

Use JSON struct tags to control field mapping. The primary key must be unique and non-null โ€” Meilisearch defaults to id if not explicitly set.

type Product struct {
    ID          int       `json:"id"`
    Name        string    `json:"name"`
    Description string    `json:"description"`
    Category    string    `json:"category"`
    Price       float64   `json:"price"`
    InStock     bool      `json:"in_stock"`
    Tags        []string  `json:"tags"`
    CreatedAt   time.Time `json:"created_at"`
}

var products = []Product{
    {ID: 1, Name: "Wireless Mouse", Description: "Ergonomic 2.4G wireless mouse with 6 buttons", Category: "Electronics", Price: 29.99, InStock: true, Tags: []string{"mouse", "wireless", "ergonomic"}, CreatedAt: time.Now()},
    {ID: 2, Name: "Mechanical Keyboard", Description: "RGB backlit mechanical keyboard with Cherry MX switches", Category: "Electronics", Price: 89.99, InStock: true, Tags: []string{"keyboard", "mechanical", "rgb"}, CreatedAt: time.Now()},
    {ID: 3, Name: "USB-C Hub", Description: "7-in-1 USB-C hub with HDMI, USB 3.0, SD card reader", Category: "Accessories", Price: 34.99, InStock: false, Tags: []string{"usb", "hub", "adapter"}, CreatedAt: time.Now()},
}

Create Index and Add Documents

func createIndexAndAddDocs(client meilisearch.ServiceManager) {
    // Create index with explicit primary key
    idx, err := client.CreateIndex(&meilisearch.IndexConfig{
        Uid:        "products",
        PrimaryKey: "id",
    })
    if err != nil {
        slog.Error("create index failed", "err", err)
        return
    }
    slog.Info("index created", "uid", idx.UID)

    // Add documents โ€” returns a task
    task, err := idx.AddDocuments(products)
    if err != nil {
        slog.Error("add documents failed", "err", err)
        return
    }

    // Block until Meilisearch finishes indexing
    task, err = client.WaitForTask(task.TaskUID, 10*time.Second)
    if err != nil {
        slog.Error("task did not complete", "err", err)
        return
    }
    slog.Info("documents indexed", "task_uid", task.TaskUID, "status", task.Status)
}

Update (Partial) and Replace (Full) Documents

UpdateDocuments performs a partial update โ€” only provided fields are changed. AddDocuments with an existing primary key replaces the entire document.

func updateAndReplace(idx meilisearch.IndexManager) {
    // Partial update: only change Price and Tags
    partial := []map[string]interface{}{
        {"id": 1, "price": 24.99, "tags": []string{"mouse", "wireless", "sale"}},
    }
    task, err := idx.UpdateDocuments(partial)
    if err != nil {
        slog.Error("update failed", "err", err)
        return
    }
    slog.Info("documents updated", "task_uid", task.TaskUID)

    // Full replace: the entire document is overwritten
    full := Product{
        ID: 1, Name: "Wireless Mouse V2",
        Description: "Updated ergonomic wireless mouse with silent clicks",
        Category: "Electronics", Price: 34.99, InStock: true,
        Tags: []string{"mouse", "wireless", "silent"},
    }
    task, err = idx.AddDocuments([]Product{full})
    if err != nil {
        slog.Error("replace failed", "err", err)
        return
    }
    slog.Info("document replaced", "task_uid", task.TaskUID)
}

Delete Documents

func deleteDocuments(idx meilisearch.IndexManager) {
    // Delete by single primary key
    task, err := idx.DeleteDocument(3)
    if err != nil {
        slog.Error("delete failed", "err", err)
        return
    }
    slog.Info("document deleted", "task_uid", task.TaskUID)

    // Delete by filter
    task, err = idx.DeleteDocumentsByFilter("in_stock = false")
    if err != nil {
        slog.Error("delete by filter failed", "err", err)
        return
    }

    // Delete all documents
    task, err = idx.DeleteAllDocuments()
    if err != nil {
        slog.Error("delete all failed", "err", err)
        return
    }
}

Search Operations

func basicSearch(idx meilisearch.IndexManager) {
    res, err := idx.Search("mechanical keyboard", &meilisearch.SearchRequest{})
    if err != nil {
        slog.Error("search failed", "err", err)
        return
    }
    slog.Info("search results", "total", res.EstimatedTotalHits, "hits", len(res.Hits))

    for _, hit := range res.Hits {
        p := hit.(map[string]interface{})
        fmt.Printf("  %s โ€” $%.2f\n", p["name"], p["price"])
    }
}

Filters use a SQL-like expression syntax. Combine conditions with AND, OR, and parentheses.

func filteredSearch(idx meilisearch.IndexManager) {
    res, err := idx.Search("mouse", &meilisearch.SearchRequest{
        Filter: "category = Electronics AND price < 30 AND in_stock = true",
        Limit:  20,
    })
    if err != nil {
        slog.Error("filtered search failed", "err", err)
        return
    }
    for _, hit := range res.Hits {
        p := hit.(map[string]interface{})
        fmt.Printf("  %s | $%.2f | %v\n", p["name"], p["price"], p["in_stock"])
    }
}

First declare which attributes are filterable, then request facet distributions in the search call.

func facetedSearch(idx meilisearch.IndexManager) {
    // Enable faceting on the index
    _, err := idx.UpdateFilterableAttributes(&[]string{"category", "tags", "in_stock"})
    if err != nil {
        slog.Error("update filterable attrs failed", "err", err)
        return
    }

    res, err := idx.Search("", &meilisearch.SearchRequest{
        Facets: []string{"category", "in_stock"},
        Limit:  0,
    })
    if err != nil {
        slog.Error("faceted search failed", "err", err)
        return
    }

    if facetDist, ok := res.FacetDistribution.(map[string]interface{}); ok {
        for attr, counts := range facetDist {
            fmt.Printf("[%s]\n", attr)
            for val, count := range counts.(map[string]interface{}) {
                fmt.Printf("  %s: %v\n", val, count)
            }
        }
    }
}

Sorting

Define sortable attributes at the index level, then pass Sort in search requests. Multiple sort criteria are applied in order.

func sortedSearch(idx meilisearch.IndexManager) {
    // Enable sorting on price
    _, err := idx.UpdateSortableAttributes(&[]string{"price", "created_at"})
    if err != nil {
        slog.Error("update sortable attrs failed", "err", err)
        return
    }

    res, err := idx.Search("", &meilisearch.SearchRequest{
        Sort: []string{"price:asc", "created_at:desc"},
        Limit: 10,
    })
    if err != nil {
        slog.Error("sorted search failed", "err", err)
        return
    }
    for _, hit := range res.Hits {
        p := hit.(map[string]interface{})
        fmt.Printf("%s โ€” $%.2f\n", p["name"], p["price"])
    }
}

Search with Highlighting and Crop

func highlightedSearch(idx meilisearch.IndexManager) {
    res, err := idx.Search("wireless", &meilisearch.SearchRequest{
        AttributesToHighlight: []string{"name", "description"},
        AttributesToCrop:      []string{"description"},
        CropLength:            30,
        ShowMatchesPosition:   true,
    })
    if err != nil {
        slog.Error("highlight search failed", "err", err)
        return
    }
    for _, hit := range res.Hits {
        p := hit.(map[string]interface{})
        fmt.Printf("Name: %s\n", p["name"])
        if fm, ok := res.FormattedHit(hit).(map[string]interface{}); ok {
            fmt.Printf("Highlighted description: %s\n", fm["description"])
        }
    }
}

Pagination

Meilisearch supports two pagination modes: traditional Offset/Limit and estimated HitsPerPage/Page.

func paginatedSearch(idx meilisearch.IndexManager) {
    // Offset-based pagination
    res, err := idx.Search("", &meilisearch.SearchRequest{
        Offset: 20,
        Limit:  10,
    })
    if err != nil {
        slog.Error("paginated search failed", "err", err)
        return
    }
    fmt.Printf("Page 3 of results โ€” %d total estimated\n", res.EstimatedTotalHits)
}

Index Management

func manageIndexes(client meilisearch.ServiceManager) {
    // List all indexes
    indexes, err := client.ListIndexes()
    if err != nil {
        slog.Error("list indexes failed", "err", err)
        return
    }
    for _, idx := range indexes {
        fmt.Printf("Index: %s (primary key: %s)\n", idx.UID, idx.PrimaryKey)
    }

    // Get a single index
    idx, err := client.GetIndex("products")
    if err != nil {
        slog.Error("get index failed", "err", err)
        return
    }

    // Update index (primary key cannot be changed)
    idx, err = client.UpdateIndex("products", &meilisearch.IndexConfig{
        PrimaryKey: "id",
    })
    if err != nil {
        slog.Error("update index failed", "err", err)
        return
    }

    // Delete index
    task, err := client.DeleteIndex("products")
    if err != nil {
        slog.Error("delete index failed", "err", err)
        return
    }
    client.WaitForTask(task.TaskUID, 5*time.Second)
    slog.Info("index deleted")
}

Settings Management

Index settings control how Meilisearch processes fields, ranks results, and handles language. All settings changes return a task uid โ€” always wait for completion before running dependent operations.

func configureSettings(idx meilisearch.IndexManager) {
    // Set which fields are searchable
    task, err := idx.UpdateSearchableAttributes(&[]string{"name", "description", "tags"})
    if err != nil {
        slog.Error("update searchable attrs failed", "err", err)
        return
    }
    client.WaitForTask(task.TaskUID, 10*time.Second)

    // Set filterable attributes
    task, err = idx.UpdateFilterableAttributes(&[]string{"category", "price", "in_stock", "tags"})
    if err != nil {
        slog.Error("update filterable attrs failed", "err", err)
        return
    }
    client.WaitForTask(task.TaskUID, 10*time.Second)

    // Set sortable attributes
    task, err = idx.UpdateSortableAttributes(&[]string{"price", "created_at"})
    if err != nil {
        slog.Error("update sortable attrs failed", "err", err)
        return
    }

    // Set displayed attributes
    task, err = idx.UpdateDisplayedAttributes(&[]string{"id", "name", "price", "category", "in_stock"})
    if err != nil {
        slog.Error("update displayed attrs failed", "err", err)
        return
    }

    // Set stop words โ€” commonly filtered out during search
    task, err = idx.UpdateStopWords(&[]string{"a", "an", "the", "is", "it", "of", "and"})
    if err != nil {
        slog.Error("update stop words failed", "err", err)
        return
    }

    // Define synonyms โ€” groups of words treated as equivalent
    synonyms := map[string][]string{
        "laptop": {"notebook", "ultrabook"},
        "mouse":  {"trackpad", "pointer"},
        "cheap":  {"affordable", "budget", "inexpensive"},
    }
    task, err = idx.UpdateSynonyms(&synonyms)
    if err != nil {
        slog.Error("update synonyms failed", "err", err)
        return
    }

    // Customise ranking rules (order matters โ€” first has highest priority)
    rankingRules := []string{
        "words",
        "typo",
        "proximity",
        "attribute",
        "sort",
        "exactness",
        "price:desc",
    }
    task, err = idx.UpdateRankingRules(&rankingRules)
    if err != nil {
        slog.Error("update ranking rules failed", "err", err)
        return
    }

    // Read current settings
    settings, err := idx.GetSettings()
    if err != nil {
        slog.Error("get settings failed", "err", err)
        return
    }
    slog.Info("current settings", "searchable", settings.SearchableAttributes)

    // Reset settings to defaults
    task, err = idx.ResetSettings()
    if err != nil {
        slog.Error("reset settings failed", "err", err)
        return
    }
}

Task Management

Every write operation in Meilisearch is asynchronous โ€” the API returns immediately with a task uid, and the actual work happens in a background queue. Always check task status before assuming data is queryable.

func taskLifecycle(client meilisearch.ServiceManager) {
    idx := client.Index("products")

    task, err := idx.AddDocuments(products)
    if err != nil {
        slog.Error("add docs failed", "err", err)
        return
    }

    // Option 1: Block with deadline
    finished, err := client.WaitForTask(task.TaskUID, 30*time.Second)
    if err != nil {
        slog.Error("task wait failed", "err", err)
        return
    }
    slog.Info("task completed",
        "uid", finished.UID,
        "status", finished.Status,
        "duration_ms", finished.Duration,
    )

    // Option 2: Poll manually
    for {
        info, err := client.GetTask(task.TaskUID)
        if err != nil {
            slog.Error("get task failed", "err", err)
            break
        }
        if info.Status == meilisearch.TaskStatusSucceeded {
            slog.Info("task succeeded", "uid", info.UID)
            break
        }
        if info.Status == meilisearch.TaskStatusFailed {
            slog.Error("task failed",
                "uid", info.UID,
                "error", info.Error.Message,
                "code", info.Error.Code,
            )
            break
        }
        time.Sleep(50 * time.Millisecond)
    }

    // Option 3: List recent tasks
    tasks, err := client.GetTasks(&meilisearch.TasksQuery{
        Limit: 10,
        Statuses: []meilisearch.TaskStatus{meilisearch.TaskStatusFailed},
    })
    if err != nil {
        slog.Error("get tasks failed", "err", err)
        return
    }
    slog.Info("recent failed tasks", "count", len(tasks.Results))
}

Hybrid Search with Vectors (Meilisearch v1.13+)

Enable hybrid search by setting a vector embedding configuration on the index, then providing vector embeddings alongside your documents. Hybrid search combines keyword-based BM25 scoring with vector similarity.

type EmbedDoc struct {
    ID       int       `json:"id"`
    Title    string    `json:"title"`
    Content  string    `json:"content"`
    _Vectors []float32 `json:"_vectors,omitempty"`
}

func hybridSearch(idx meilisearch.IndexManager) {
    // Configure vector search (run once during setup)
    embedder := meilisearch.EmbedderConfig{
        Source:   "userProvided",
        Name:     "default",
        Model:    "text-embedding-ada-002",
        Distance: "cosine",
    }
    task, err := idx.UpdateEmbedders(&[]meilisearch.EmbedderConfig{embedder})
    if err != nil {
        slog.Error("update embedders failed", "err", err)
        return
    }
    client.WaitForTask(task.TaskUID, 30*time.Second)

    // Add documents with vector embeddings
    // In production, generate embeddings via an external API (OpenAI, etc.)
    doc := EmbedDoc{
        ID: 101, Title: "Go Concurrency Patterns",
        Content: "Goroutines and channels make concurrency in Go expressive...",
        _Vectors: []float32{0.012, -0.034, 0.089, 0.215, -0.101}, // truncated example
    }
    task, err = idx.AddDocuments([]EmbedDoc{doc})
    client.WaitForTask(task.TaskUID, 10*time.Second)

    // Hybrid search: combine semantic + keyword
    res, err := idx.Search("goroutine channels", &meilisearch.SearchRequest{
        Hybrid: &meilisearch.Hybrid{
            SemanticRatio: 0.7,
        },
    })
    if err != nil {
        slog.Error("hybrid search failed", "err", err)
        return
    }
    for _, hit := range res.Hits {
        fmt.Printf("Score: %v โ€” %v\n", hit["_semanticScore"], hit["title"])
    }
}

Search across multiple indexes in a single request and optionally merge results.

func federatedSearch(client meilisearch.ServiceManager) {
    // Use the multi-search endpoint
    multiReq := &meilisearch.MultiSearchRequest{
        Queries: []meilisearch.MultiSearchQuery{
            {IndexUID: "products", Q: "keyboard", Limit: 5},
            {IndexUID: "articles", Q: "keyboard", Limit: 5},
        },
        Federated: true, // merge and rank across indexes
    }
    res, err := client.MultiSearch(multiReq)
    if err != nil {
        slog.Error("multi-index search failed", "err", err)
        return
    }
    slog.Info("federated results", "total", res.EstimatedTotalHits)
    for _, hit := range res.Hits {
        fmt.Printf("[%s] %v\n", hit.IndexUID, hit.Hit.(map[string]interface{})["name"])
    }
}

Error Handling Patterns

Meilisearch errors carry a StatusCode, Code, and Message. Wrap API calls for consistent error handling across your application.

func searchWithErrorHandling(idx meilisearch.IndexManager) {
    res, err := idx.Search("mouse", &meilisearch.SearchRequest{})
    if err != nil {
        // Check if it's a Meilisearch API error
        if apiErr, ok := err.(*meilisearch.Error); ok {
            switch apiErr.StatusCode {
            case 401:
                slog.Error("authentication failed โ€” check API key")
            case 404:
                slog.Error("index not found โ€” ensure it exists")
            case 429:
                slog.Error("rate limited โ€” reduce request frequency")
            default:
                slog.Error("meilisearch error",
                    "code", apiErr.Code,
                    "message", apiErr.Message,
                )
            }
        } else {
            slog.Error("network or client error", "err", err)
        }
        return
    }

    // Semantic check for empty results
    if res.EstimatedTotalHits == 0 {
        slog.Warn("no results found โ€” consider broadening filters")
    }
}

Production Considerations

Connection Pooling and Retry Middleware

Wrap the Meilisearch client with an HTTP transport that pools connections and retries on transient failures.

type retryRoundTripper struct {
    next    http.RoundTripper
    maxRetry int
    baseWait time.Duration
}

func (r *retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
    var resp *http.Response
    var err error
    for attempt := 0; attempt <= r.maxRetry; attempt++ {
        resp, err = r.next.RoundTrip(req)
        if err == nil && resp.StatusCode < 500 {
            return resp, nil
        }
        if resp != nil {
            resp.Body.Close()
        }
        select {
        case <-req.Context().Done():
            return nil, req.Context().Err()
        case <-time.After(r.baseWait * time.Duration(1<<attempt)):
        }
    }
    return resp, err
}

func newProductionClient() meilisearch.ServiceManager {
    baseTransport := &http.Transport{
        MaxIdleConns:        256,
        MaxIdleConnsPerHost: 64,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 10 * time.Second,
    }
    client := &http.Client{
        Transport: &retryRoundTripper{
            next:     baseTransport,
            maxRetry: 3,
            baseWait: 100 * time.Millisecond,
        },
        Timeout: 30 * time.Second,
    }
    cfg := &meilisearch.ClientConfig{
        Host:       "https://search.example.com",
        APIKey:     os.Getenv("MEILI_MASTER_KEY"),
        HttpClient: client,
    }
    return meilisearch.NewClient(*cfg)
}

Caching Strategy

Cache frequent searches with an in-memory or Redis-backed layer to reduce Meilisearch load.

var searchCache = ttlcache.New[string, *meilisearch.SearchResponse]()

func cachedSearch(idx meilisearch.IndexManager, query string, opts *meilisearch.SearchRequest) (*meilisearch.SearchResponse, error) {
    cacheKey := fmt.Sprintf("%s:%v", query, opts)
    if cached, ok := searchCache.Get(cacheKey); ok {
        return cached, nil
    }

    res, err := idx.Search(query, opts)
    if err != nil {
        return nil, err
    }

    searchCache.Set(cacheKey, res, 30*time.Second)
    return res, nil
}

Metrics Collection

Export Meilisearch query latency and error counts to Prometheus for operational visibility.

var (
    searchDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name: "meilisearch_search_duration_ms",
        Help: "Latency of Meilisearch searches",
        Buckets: []float64{5, 10, 25, 50, 100, 250, 500, 1000},
    }, []string{"index"})
    searchErrors = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "meilisearch_search_errors_total",
        Help: "Total Meilisearch search errors",
    }, []string{"index", "error_code"})
)

func instrumentedSearch(idx meilisearch.IndexManager, query string, opts *meilisearch.SearchRequest) (*meilisearch.SearchResponse, error) {
    start := time.Now()
    res, err := idx.Search(query, opts)
    elapsed := time.Since(start).Milliseconds()

    if err != nil {
        searchErrors.WithLabelValues(idx.UID, "search_error").Inc()
        return nil, err
    }
    searchDuration.WithLabelValues(idx.UID).Observe(float64(elapsed))
    return res, nil
}

Client API Reference Table

Method Description
New(host, opts) Create a new client with host URL and optional config
NewClient(cfg) Create client from a ClientConfig struct
Client.Health() Check server health and version
Client.CreateIndex(cfg) Create a new index with primary key
Client.GetIndex(uid) Fetch metadata for an existing index
Client.ListIndexes() List all indexes
Client.UpdateIndex(uid, cfg) Update index settings
Client.DeleteIndex(uid) Delete an index
Client.Index(uid) Get an IndexManager for the given uid
Index.AddDocuments(docs) Add or replace documents
Index.UpdateDocuments(docs) Partial update on existing documents
Index.DeleteDocument(id) Delete a single document by primary key
Index.DeleteDocuments(ids) Delete multiple documents by primary key
Index.DeleteDocumentsByFilter(f) Delete documents matching a filter
Index.DeleteAllDocuments() Remove all documents from the index
Index.Search(query, req) Perform a search with optional filters, facets, sort, etc.
Index.GetDocument(id) Retrieve a single document
Index.GetDocuments(req) List documents with optional pagination
Index.GetSettings() Read all index settings
Index.UpdateSearchableAttributes(v) Set which fields are searchable
Index.UpdateFilterableAttributes(v) Set fields usable in filter expressions
Index.UpdateSortableAttributes(v) Set fields usable in sort
Index.UpdateDisplayedAttributes(v) Restrict fields returned in search results
Index.UpdateStopWords(v) Set words to ignore during search
Index.UpdateSynonyms(v) Define word synonym groups
Index.UpdateRankingRules(v) Customize ranking criteria
Index.UpdateEmbedders(v) Configure vector search (v1.13+)
Client.GetTask(uid) Fetch a single task by its uid
Client.GetTasks(query) List tasks with optional filtering
Client.WaitForTask(uid, timeout) Block until a task completes
Client.MultiSearch(req) Search multiple indexes in one request

Comparison With Other Go Search Libraries

Feature Meilisearch Bleve Zinc (zincsearch)
Engine type RESTful server (Rust) Embedded Go library RESTful server (Go)
Typo tolerance Built-in, auto Manual via fuzzy queries Built-in
Faceting Native with distributions Custom aggregation Native
Vector/hybrid search Supported (v1.13+) Not supported Not supported
Deployment Self-hosted or cloud In-process Self-hosted or cloud
Index persistence Automatic (disk) Requires manual indexing config Automatic (disk)
Go client Official maintained client Native Go API Official client
Learning curve Low Medium Low-medium
Best for Fast standalone search Embedded search in Go apps Elasticsearch replacement

When to choose Meilisearch: you need a fast, standalone search service with minimal configuration and features like typo tolerance and faceting out of the box.

When to choose Bleve: you want an embedded search engine that runs in the same process as your Go application with no external dependencies.

When to choose Zinc: you need an Elasticsearch-compatible API with a lightweight footprint but want to stay within the Go ecosystem.

Migration Patterns from Other Search Engines

From Elasticsearch

  • Replace _source inclusion/exclusion with displayedAttributes.
  • Replace multi_match with Meilisearch’s default cross-field search.
  • Replace term/match/fuzzy with plain text queries (typo tolerance is automatic).

From Bleve

  • Move index storage from embedded BoltDB/Forestdb path to Meilisearch server.
  • Change document indexing from index.Index() to idx.AddDocuments().
  • Replace query.NewMatchQuery() with Meilisearch search strings and filter expressions.
// Bleve equivalent to Meilisearch
// Bleve:   query.NewTermQuery("electronics") -> searcher
// Meilisearch: idx.Search("", &meilisearch.SearchRequest{Filter: "category = Electronics"})

Conclusion

Go and Meilisearch form a powerful combination for adding fast, typo-tolerant search to any application. The Go client covers the full Meilisearch API โ€” documents, search, settings, tasks, and vector search โ€” with clear abstractions. Start with the basic client and CRUD examples, add settings tuning as your search requirements grow, and layer in production patterns like caching, retries, and metrics when you deploy.

Resources

Comments