How to Connect to Elasticsearch from Go

Connect to Elasticsearch from Go using the official elastic/go-elasticsearch client library with a minimal configuration example.

When raw HTTP gets in the way

You are building a search endpoint. Users type a query, your backend needs to fetch matching records, and Elasticsearch is sitting on localhost:9200 waiting for requests. You could craft raw HTTP calls with net/http, marshal JSON by hand, and manage connection lifecycles yourself. Or you can use the official Go client, which handles the plumbing so you can focus on the query logic.

Elasticsearch is fundamentally an HTTP API. Every index, search, or bulk operation is just a JSON payload sent to a specific endpoint. The elastic/go-elasticsearch package wraps that HTTP layer. It manages connection pooling, handles retries on transient failures, and provides typed methods for common operations. Think of it like a dedicated freight forwarder. You hand them a box with a label, they handle customs, routing, and delivery confirmation. You just need to know where the box is going and what is inside it.

How the client actually works

The package does not reinvent networking. It builds on Go's standard net/http client and adds Elasticsearch-specific routing and retry logic. When you call NewClient, it parses your address list and constructs an internal transport. It does not open a TCP connection yet. The handshake happens on the first request.

Every method you call, like es.Info() or es.Search(), constructs an HTTP request under the hood. It sets the correct path, attaches your context, and streams your JSON payload. The response object implements io.ReadCloser. This design choice keeps the package lightweight. Instead of returning pre-parsed structs for every possible Elasticsearch response, it hands you the raw HTTP response. You decode what you need. This avoids heavy reflection dependencies and gives you full control over error handling and memory allocation.

The client follows Go conventions strictly. Every method that talks to the network accepts a context.Context as its first parameter. The community names it ctx by default. Functions that receive a context should respect cancellation and deadlines. If the parent context expires, the HTTP request aborts immediately. The error handling pattern is explicit. You check err, then check res.IsError(), then read the body. This verbosity is intentional. It forces you to acknowledge the unhappy path instead of hiding it behind silent failures.

Trust the standard library. Let the client handle routing. Decode only what you need.

Minimal setup

Here is the smallest working configuration. It creates a client, pings the cluster, and prints the cluster name.

package main

import (
	"context"
	"log"

	"github.com/elastic/go-elasticsearch/v8"
)

func main() {
	// Point to the cluster address. The client handles TLS verification automatically.
	cfg := elasticsearch.Config{
		Addresses: []string{"http://localhost:9200"},
	}
	// NewClient returns an initialized client or an error if the config is invalid.
	es, err := elasticsearch.NewClient(cfg)
	if err != nil {
		log.Fatal(err)
	}
	// Context carries cancellation and deadlines. Always pass it as the first argument.
	ctx := context.Background()
	// Info fetches cluster metadata. It returns an HTTP response body and an error.
	res, err := es.Info(ctx)
	if err != nil {
		log.Fatal(err)
	}
	// Check the HTTP status code. Elasticsearch returns 200 on success.
	defer res.Body.Close()
	if res.IsError() {
		log.Fatal(res.String())
	}
	log.Println(res.String())
}

Walking through the request lifecycle

When es.Info(ctx) executes, the client builds a GET / request. It attaches the context, selects an available address from your configuration, and sends the request over the transport. The transport manages a connection pool. If a TCP connection is already open and idle, it reuses it. If not, it opens a new one. This pooling prevents the overhead of repeated TLS handshakes and TCP three-way handshakes.

The response arrives as a stream. You must close the body to return the connection to the pool. Forgetting to close it leaks file descriptors and eventually crashes your process. The defer res.Body.Close() pattern is standard Go practice. It guarantees cleanup even if a subsequent line panics.

If the cluster is unreachable, err contains a network error. If the cluster is reachable but returns a 4xx or 5xx status, err is nil and res.IsError() returns true. This separation lets you distinguish between transport failures and application-level rejections. You can log transport errors differently from bad requests. You can retry network timeouts but skip retrying malformed queries.

Close the body. Respect the status code. Let the transport manage the pool.

Realistic indexing and search flow

A real application indexes documents and searches them. Here is how you structure that flow with proper error handling and JSON mapping.

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"

	"github.com/elastic/go-elasticsearch/v8"
)

// Article represents a document stored in Elasticsearch.
type Article struct {
	Title   string   `json:"title"`
	Content string   `json:"content"`
	Tags    []string `json:"tags"`
}

// IndexArticle sends a document to the target index.
func IndexArticle(ctx context.Context, es *elasticsearch.Client, id string, doc Article) error {
	// Marshal the struct into JSON bytes for the request body.
	body, err := json.Marshal(doc)
	if err != nil {
		return err
	}
	// Index creates or updates a document. The ID parameter maps to the _id field.
	res, err := es.Index(
		"articles",
		bytes.NewReader(body),
		es.Index.WithDocumentID(id),
		es.Index.WithPretty(),
	)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	// Elasticsearch returns 201 Created or 200 OK. Anything else is a failure.
	if res.IsError() {
		return fmt.Errorf("index failed: %s", res.String())
	}
	return nil
}

The search function requires parsing the response. Elasticsearch wraps hits in nested JSON objects. You decode only the fields you need.

// SearchArticles runs a query against the articles index.
func SearchArticles(ctx context.Context, es *elasticsearch.Client, query string) ([]Article, error) {
	// Build the query DSL as a raw JSON string. The client accepts any valid JSON.
	q := fmt.Sprintf(`{"query": {"match": {"title": "%s"}}}`, query)
	res, err := es.Search(
		es.Search.WithContext(ctx),
		es.Search.WithIndex("articles"),
		es.Search.WithBody(strings.NewReader(q)),
		es.Search.WithPretty(),
	)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.IsError() {
		return nil, fmt.Errorf("search failed: %s", res.String())
	}
	// Parse the response into a temporary struct to extract hits.
	var result struct {
		Hits struct {
			Hits []struct {
				Source Article `json:"_source"`
			} `json:"hits"`
		} `json:"hits"`
	}
	if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
		return nil, err
	}
	// Map the nested hits into a flat slice of Articles.
	articles := make([]Article, 0, len(result.Hits.Hits))
	for _, h := range result.Hits.Hits {
		articles = append(articles, h.Source)
	}
	return articles, nil
}

Context propagation and cancellation

Long-running queries or bulk indexing operations can hang if the cluster is under heavy load. Context deadlines prevent your goroutines from blocking indefinitely. When you pass a context with a deadline, the HTTP transport watches the timer. If the deadline expires before the response arrives, the transport cancels the request and returns a context.DeadlineExceeded error.

// Create a context that cancels after 3 seconds.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

// Pass the context to the search call. The transport respects the deadline.
articles, err := SearchArticles(ctx, es, "golang")
if err != nil {
	// Handle timeout or cancellation gracefully.
	log.Printf("search aborted: %v", err)
	return
}

The defer cancel() call releases the context's resources. Skipping it leaks memory in the context tree. The compiler does not catch this. You must write it explicitly. Context is plumbing. Run it through every long-lived call site.

Pitfalls and compiler guardrails

Connection timeouts are the most common runtime issue. The default HTTP client used by the Elasticsearch package inherits Go's net/http defaults, which means no timeout on the request body read. If Elasticsearch hangs, your goroutine blocks forever. Configure the transport explicitly:

cfg := elasticsearch.Config{
	Addresses: []string{"http://localhost:9200"},
	Transport: &http.Transport{
		// Set a 5-second deadline for the entire request lifecycle.
		ResponseHeaderTimeout: 5 * time.Second,
		// Reuse connections to avoid TCP handshake overhead.
		MaxIdleConns:        10,
		MaxIdleConnsPerHost: 10,
	},
}

If you forget to pass a context or pass a nil context, the compiler rejects the program with cannot use nil as context.Context value in argument. The type system enforces the plumbing. If you ignore the response body and skip defer res.Body.Close(), the program compiles fine. The failure happens at runtime when your process exhausts file descriptors. The worst goroutine bug is the one that never logs. Always close response bodies and respect context cancellation.

Another trap is assuming the client handles JSON unmarshaling automatically. The official client returns raw *http.Response objects for almost every operation. You must decode the body yourself. This design keeps the package lightweight and avoids pulling in heavy reflection dependencies. It also means you control the struct tags and error handling. If you pass a malformed query string, Elasticsearch returns a 400 status code. The client does not panic. It hands you the error response, and you decide whether to retry, log, or fail fast.

Public names start with a capital letter. Private start lowercase. No keywords like public or private. The client methods follow this rule. Index, Search, and Bulk are exported. Internal helpers are not. Trust gofmt. Argue logic, not formatting.

Decision matrix

Use the official elastic/go-elasticsearch client when you need a lightweight, well-maintained wrapper that handles connection pooling and retries without adding heavy dependencies. Use raw net/http when you are building a custom proxy or need granular control over every header and retry strategy. Use a higher-level ORM-style wrapper when you prefer method chaining and automatic JSON mapping over explicit control. Use a message queue or batch processor when you are indexing millions of documents and need to throttle throughput to protect the cluster. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.

Where to go next