How to Use etcd Client in Go

Use the official `go.etcd.io/etcd/client/v3` package to create a client, configure it with your cluster endpoints and timeout, and then perform operations like Put, Get, or Watch via the returned client object.

When a single machine isn't enough

You are building a service that needs to share a single configuration value across three different pods. Or maybe you need a reliable way to elect a leader among a fleet of workers. A local file won't cut it. A full relational database feels like bringing a sledgehammer to a nail. You need a distributed key-value store that handles replication, consensus, and concurrent access without you writing the networking code. That is what etcd does. The official Go client wraps the gRPC protocol into a familiar API, but it expects you to handle lifecycles and network timeouts explicitly.

The client as a network wrapper

Think of etcd as a shared whiteboard in a secure server room. Multiple engineers can walk up, write a value, or watch for changes. The Go client is your badge, your marker, and your eraser. Under the hood, the client maintains a connection pool to the etcd cluster. Every operation you call translates into a gRPC request. Because gRPC is network-aware, every method requires a context.Context as its first argument. This is not optional boilerplate. The context carries deadlines, cancellation signals, and metadata. If you ignore it, your program will hang waiting for a response that never arrives. Context is plumbing. Run it through every long-lived call site.

Minimal setup

Here is the simplest way to connect, write a value, and read it back. The client handles connection pooling automatically once you pass a valid configuration.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	clientv3 "go.etcd.io/etcd/client/v3"
)

func main() {
	// Point to the cluster. The client will retry failed endpoints automatically.
	cfg := clientv3.Config{
		Endpoints:   []string{"localhost:2379"},
		DialTimeout: 5 * time.Second, // Fail fast if the cluster is unreachable
	}

	// Initialize the client. It opens a gRPC connection pool under the hood.
	cli, err := clientv3.New(cfg)
	if err != nil {
		log.Fatal("failed to create client: ", err)
	}
	defer cli.Close() // Release connections when main exits

	// Set a deadline for this specific operation.
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// Write a key-value pair. The client serializes the request and waits for consensus.
	_, err = cli.Put(ctx, "app/config", "v2.1.0")
	if err != nil {
		log.Fatal("failed to put key: ", err)
	}

	// Read the value back. etcd returns a slice of key-value pairs.
	resp, err := cli.Get(ctx, "app/config")
	if err != nil {
		log.Fatal("failed to get key: ", err)
	}

	if len(resp.Kvs) > 0 {
		fmt.Printf("Current config: %s\n", string(resp.Kvs[0].Value))
	}
}

What happens under the hood

The program starts by defining a clientv3.Config. You supply the cluster endpoints and a dial timeout. The client does not connect immediately. It waits until you call Put or Get to establish the first gRPC connection. This lazy initialization saves resources if the program exits early. When cli.Put runs, it takes the context, serializes the key and value into a protobuf message, and sends it over the network. The etcd cluster runs the Raft consensus algorithm to agree on the new state. Once a majority of nodes acknowledge the write, the client returns. The Get call works similarly but reads from the local node's log first, falling back to the cluster if needed. Notice the defer cli.Close() call. The client holds open TCP connections and background goroutines for keep-alives and reconnection logic. Skipping the close call leaks file descriptors and goroutines. Always pair New with Close. The if err != nil pattern looks verbose, but it makes the unhappy path visible. The community accepts the boilerplate because silent failures in distributed systems are worse than extra lines.

Watching for changes

Static reads and writes cover basic cases. Real systems need to react to changes. etcd exposes a watch API that streams events over a channel. You run the watch in a background goroutine and process events as they arrive. This pattern is common for configuration reloads or service discovery updates.

// Watch a key and react to changes. Run this in a goroutine.
func watchConfig(ctx context.Context, cli *clientv3.Client) {
	// Create a watch channel. The client handles reconnection and resume logic.
	ch := cli.Watch(ctx, "app/config")

	// Process events as they stream in.
	for wresp := range ch {
		for _, ev := range wresp.Events {
			// Skip delete events if you only care about updates.
			if ev.Type == clientv3.EventTypeDelete {
				continue
			}
			fmt.Printf("Config updated to: %s\n", string(ev.Kv.Value))
		}
	}
}

You call watchConfig from main by passing the same context you created earlier. When the context expires or gets cancelled, the watch channel closes. The for range loop exits cleanly, and the goroutine finishes. This is how you prevent goroutine leaks. The context is your cancellation signal. The worst goroutine bug is the one that never logs. Always verify your watch goroutine exits when the parent context dies.

Managing lifecycles with leases

Keys in etcd live forever until you delete them. That behavior breaks leader election and session tracking. You need keys that expire automatically when a client crashes or loses network connectivity. etcd solves this with leases. A lease is a time-to-live attached to a key. The client sends periodic keep-alive requests to extend the lease. If the keep-alive stops, the server expires the key and fires a watch event.

// Grant a lease and attach it to a key.
func registerWithLease(ctx context.Context, cli *clientv3.Client) error {
	// Create a lease that expires after 10 seconds without a keep-alive.
	leaseResp, err := cli.Grant(ctx, 10)
	if err != nil {
		return fmt.Errorf("grant lease: %w", err)
	}

	// Attach the lease ID to the key. The key dies if the lease expires.
	_, err = cli.Put(ctx, "leader/election", "node-1", clientv3.WithLease(leaseResp.ID))
	if err != nil {
		return fmt.Errorf("put with lease: %w", err)
	}

	// Start a keep-alive channel to extend the lease automatically.
	keepAliveCh, err := cli.KeepAlive(ctx, leaseResp.ID)
	if err != nil {
		return fmt.Errorf("keep alive: %w", err)
	}

	// Consume keep-alive responses to prevent channel blocking.
	go func() {
		for range keepAliveCh {
			// Intentionally empty. The channel just needs to be drained.
		}
	}()

	return nil
}

The KeepAlive call returns a channel that streams renewal confirmations. If you ignore the channel, it fills up and blocks the internal renewal goroutine. The lease expires, and your key vanishes. Always range over the keep-alive channel or discard it with _ if you only need the background renewal. The Go convention of accepting interfaces and returning structs applies here too. The client returns concrete response structs so you can inspect fields like Lease.ID or resp.Kvs. You pass the clientv3.Client interface to functions, which makes testing and mocking straightforward.

Pitfalls and runtime behavior

The etcd client is robust, but it exposes network realities. If you pass a context without a deadline to a Get or Put call, and the network drops, your goroutine blocks forever. The runtime will eventually complain with a goroutine leak warning during shutdown. Always attach a timeout or use a parent context that respects cancellation. If you forget to import the client package, the compiler rejects the program with undefined: clientv3. If you pass a string where a byte slice is expected, you get cannot use "value" (untyped string constant) as []byte value in argument. The Go compiler is strict about types for a reason. Network payloads are bytes, not strings.

Another common trap is ignoring the watch channel. If you call cli.Watch and never range over the returned channel, the client buffers events until memory fills up. The channel blocks, the goroutine hangs, and your service degrades. Always consume the channel or cancel the context immediately if you no longer need updates. TLS configuration follows the same pattern as the standard library. Add a tls.Config to clientv3.Config and the client handles certificate verification automatically. Authentication works by calling cli.Auth or cli.User methods before your first operation. The client caches the session token and reuses it. If you try to perform an operation without authenticating first, the server returns a permission denied error that bubbles up as a standard Go error. Wrap it with fmt.Errorf to preserve the stack trace. Don't fight the type system. Wrap the value or change the design.

Choosing the right tool

Use the etcd client when you need a distributed, strongly consistent key-value store that survives node failures. Use a local configuration file or environment variables when your service runs as a single instance and does not need runtime updates. Use Redis when you need high-throughput caching, pub/sub, or data structures like sorted sets, but can tolerate eventual consistency. Use direct gRPC calls when you are building a custom microservice architecture and do not need built-in consensus or lease management. Use etcd leases when you need automatic key expiration for leader election or session tracking. Use a database transaction when you need complex relational queries and ACID guarantees across multiple tables.

Where to go next