Skip to content
gohacks.dev
Databases

Redis in Go With the go redis Client Library Explained

Use Redis from Go with go-redis v9: strings, hashes, pipelines, WATCH, scripts and pool stats, plus the context deadline option that is off by default

13 min read
Summarize in AI
Flat illustration of a database beside the table it holds, with the Redis logo.

Redis is the other database most Go services end up with: a cache, a rate limiter, a queue, a place for sessions and locks. go-redis is the client most of them use. It is a thin, typed wrapper over Redis commands with a connection pool built in, and most of what you need to learn is not the API but the handful of behaviours that surprise people. This guide covers the client from first command to pool statistics on Go 1.25.6 with go-redis v9.17.0 and Redis 7, with a lab that records the output of every point.

Tested with Go 1.25.6, go-redis v9.17.0, Redis 7 (redis:7-alpine) in Docker, macOS on Apple Silicon (arm64)

It belongs to the database cornerstone. The caching patterns built on it are in Redis caching patterns, locks in distributed locks, and messaging in Redis Streams and Pub/Sub.

Starting the lab

The lab runs against a local Redis started with one command:

Shell
docker run -d --name gh-lab-redis -p 127.0.0.1:56379:6379 redis:7-alpine
main.goGo
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"sync"
	"time"

	"github.com/redis/go-redis/v9"
)

func main() {
	ctx := context.Background()
	rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:56379", PoolSize: 10, ReadTimeout: time.Second})
	defer rdb.Close()
	if err := rdb.FlushDB(ctx).Err(); err != nil {
		log.Fatal(err)
	}

	// 1. Strings, a missing key and expiry.
	_ = rdb.Set(ctx, "greeting", "hello", 0).Err()
	v, _ := rdb.Get(ctx, "greeting").Result()
	_, err := rdb.Get(ctx, "nothing").Result()
	fmt.Printf("1. get: %q, missing key: %v (redis.Nil: %v)\n", v, err, errors.Is(err, redis.Nil))
	_ = rdb.Set(ctx, "session", "abc", 1500*time.Millisecond).Err()
	ttl, _ := rdb.PTTL(ctx, "session").Result()
	fmt.Println("   pttl of a key set with 1.5s:", ttl.Round(100*time.Millisecond), "| missing key:", mustDur(rdb.PTTL(ctx, "nothing")), "| key without expiry:", mustDur(rdb.PTTL(ctx, "greeting")))

	// 2. A hash scanned into a struct.
	type User struct {
		Name  string `redis:"name"`
		Plan  string `redis:"plan"`
		Quota int    `redis:"quota"`
	}
	_ = rdb.HSet(ctx, "user:1", User{"ada", "pro", 100}).Err()
	var u User
	_ = rdb.HGetAll(ctx, "user:1").Scan(&u)
	fmt.Printf("2. hash scanned into a struct: %+v\n", u)

	// 3. INCR is atomic, so eight goroutines never lose an update.
	var wg sync.WaitGroup
	for w := 0; w < 8; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := 0; i < 250; i++ {
				rdb.Incr(ctx, "hits")
			}
		}()
	}
	wg.Wait()
	hits, _ := rdb.Get(ctx, "hits").Int()
	fmt.Println("3. eight goroutines, 250 INCR each:", hits)

	// 4. Pipelining: 2,000 SETs one at a time and in one round trip.
	start := time.Now()
	for i := 0; i < 2000; i++ {
		rdb.Set(ctx, fmt.Sprintf("k:%d", i), i, 0)
	}
	single := time.Since(start)
	start = time.Now()
	pipe := rdb.Pipeline()
	for i := 0; i < 2000; i++ {
		pipe.Set(ctx, fmt.Sprintf("p:%d", i), i, 0)
	}
	cmds, _ := pipe.Exec(ctx)
	piped := time.Since(start)
	fmt.Printf("4. 2000 SETs: one by one %v, pipelined %v (%d replies)\n", single.Round(time.Millisecond), piped.Round(time.Millisecond), len(cmds))

	// 5. WATCH: a transaction that fails when the key changes underneath it, and the retry loop.
	_ = rdb.Set(ctx, "balance", 100, 0).Err()
	attempts, conflicts := 0, 0
	for w := 0; w < 4; w++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for i := 0; i < 10; i++ {
				for {
					err := rdb.Watch(ctx, func(tx *redis.Tx) error {
						b, err := tx.Get(ctx, "balance").Int()
						if err != nil {
							return err
						}
						_, err = tx.TxPipelined(ctx, func(p redis.Pipeliner) error {
							p.Set(ctx, "balance", b+1, 0)
							return nil
						})
						return err
					}, "balance")
					mu.Lock()
					attempts++
					if errors.Is(err, redis.TxFailedErr) {
						conflicts++
					}
					mu.Unlock()
					if !errors.Is(err, redis.TxFailedErr) {
						break
					}
				}
			}
		}()
	}
	wg.Wait()
	bal, _ := rdb.Get(ctx, "balance").Int()
	fmt.Printf("5. four goroutines adding 10 each through WATCH: balance %d (want 140), %d attempts, %d conflicts retried\n", bal, attempts, conflicts)

	// 6. A Lua script runs atomically on the server.
	script := redis.NewScript(`
		local n = redis.call('INCR', KEYS[1])
		if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
		return n`)
	var counts []int64
	for i := 0; i < 3; i++ {
		n, _ := script.Run(ctx, rdb, []string{"rate:ada"}, 60).Int64()
		counts = append(counts, n)
	}
	fmt.Println("6. fixed window counter from a script:", counts, "ttl set:", mustDur(rdb.TTL(ctx, "rate:ada")) > 0)

	// 7. A blocking command with a context deadline. go-redis ignores the deadline unless the option is on.
	strict := redis.NewClient(&redis.Options{Addr: "127.0.0.1:56379", ContextTimeoutEnabled: true})
	defer strict.Close()
	for _, c := range []struct {
		name   string
		client *redis.Client
	}{{"default options", rdb}, {"ContextTimeoutEnabled: true", strict}} {
		short, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
		start = time.Now()
		_, err = c.client.BLPop(short, 2*time.Second, "empty-list").Result()
		cancel()
		fmt.Printf("7. BLPOP, 300ms context, 2s command timeout, %-28s %v after %v\n", c.name+":", err, time.Since(start).Round(50*time.Millisecond))
	}

	st := rdb.PoolStats()
	fmt.Printf("8. pool: hits=%d misses=%d timeouts=%d total=%d idle=%d\n", st.Hits, st.Misses, st.Timeouts, st.TotalConns, st.IdleConns)
}

var mu sync.Mutex

func mustDur(c *redis.DurationCmd) time.Duration {
	d, _ := c.Result()
	if d > 0 {
		return d.Round(100 * time.Millisecond)
	}
	return d
}
Text
1. get: "hello", missing key: redis: nil (redis.Nil: true)
   pttl of a key set with 1.5s: 1.5s | missing key: -2ns | key without expiry: -1ns
2. hash scanned into a struct: {Name:ada Plan:pro Quota:100}
3. eight goroutines, 250 INCR each: 2000
4. 2000 SETs: one by one 710ms, pipelined 2ms (2000 replies)
5. four goroutines adding 10 each through WATCH: balance 140 (want 140), 126 attempts, 86 conflicts retried
6. fixed window counter from a script: [1 2 3] ttl set: true
7. BLPOP, 300ms context, 2s command timeout, default options:             redis: nil after 2.05s
7. BLPOP, 300ms context, 2s command timeout, ContextTimeoutEnabled: true: read tcp 127.0.0.1:56164->127.0.0.1:56379: i/o timeout after 300ms
8. pool: hits=4137 misses=8 timeouts=0 total=8 idle=8

redis.NewClient(&redis.Options{...}) returns a *redis.Client. It is safe for concurrent use by many goroutines and holds a pool of connections, so create one per Redis server at start-up and share it, as you would a *sql.DB. Closing it on shutdown with defer rdb.Close() releases the pool. Every command takes a context.Context as its first argument and returns a command object whose Result(), Val() and Err() give the value and the error.

Strings, missing keys and expiry

The first block writes a key, reads it and reads a key that does not exist. The read of the missing key returned the error redis: nil, and errors.Is(err, redis.Nil) was true. redis.Nil is not a failure, it is the answer "no such key", and it must be told apart from real errors such as a network failure. Treat it the way you treat sql.ErrNoRows:

Go
v, err := rdb.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
    // not cached
case err != nil:
    // Redis is down or slow: decide whether to fail or fall back
default:
    use(v)
}

Expiry is set with the third argument of Set (zero means none). The lab reads it back with PTTL, which reports milliseconds: 1.5s for the key set with 1.5 seconds, -2ns for a key that does not exist and -1ns for one that exists without an expiry. Those negative durations are Redis's error codes surfaced as durations, and a common bug is treating -1 as "about to expire". Prefer PTTL to TTL: TTL rounds to whole seconds.

Hashes and structs

HSet accepts a struct with redis tags and HGetAll(...).Scan(&u) fills one back, so a small record is two calls. The lab stored {Name:ada Plan:pro Quota:100} and read it back unchanged. Hashes suit records whose fields you update separately (HIncrBy on one counter without rewriting the rest). All values are strings on the wire; the library converts numbers on the way in and out, and a field that does not parse as the target type returns an error from Scan.

Atomic commands

Redis executes one command at a time, so a single command is atomic. Eight goroutines each ran Incr 250 times against one key and the result was 2000: no lost updates, no locks. This is the reason to prefer commands like INCR, HINCRBY, SADD and ZADD over a read, modify, write cycle in Go, which has the lost update problem described in optimistic locking.

Pipelines

Every command is a network round trip. 2,000 Set calls in a loop took 710ms, a third of a millisecond each. The same 2,000 queued on a Pipeline and sent with Exec took 2ms, with 2,000 replies. The pipeline wrote all the commands to the socket, then read all the replies. This is the cheapest speed up in the library. Use pipelines for bulk writes, for reading many keys (MGet for strings, a pipeline of HGetAll for hashes) and whenever a loop issues independent commands.

A pipeline is not a transaction. Commands from other clients can interleave, and a failure of one command does not stop the others. pipe.Exec returns the commands and the first error, and each command has its own Err(); check them if individual results matter.

Transactions with MULTI and WATCH

TxPipeline wraps queued commands in MULTI and EXEC, so they run together without interleaving. It does not let you read and then decide inside the transaction, because the commands are queued before any reply. For read, decide, write, use optimistic locking with WATCH.

In the lab four goroutines each add one to a balance ten times using Watch(ctx, fn, "balance"). The function reads the balance, then queues a Set in a TxPipelined. If another client changed the watched key between the read and EXEC, Redis aborts the transaction and go-redis returns redis.TxFailedErr; the caller loops. The result was a balance of 140, correct, after 126 attempts of which 86 were retried. The cost of contention is visible: a hot key under WATCH retries a lot. If the update is a delta, INCR needs no retry at all.

Scripts

A Lua script runs atomically on the server and is the right tool when the logic needs to read and write several keys together. redis.NewScript(src) caches the script by its hash and sends EVALSHA, falling back to EVAL when the server has not seen it. The lab's script increments a counter and sets an expiry only when the counter was created, which is a fixed window rate limiter in four lines: it returned [1 2 3] and left an expiry set. Scripts block the server while they run, so keep them short and never loop over unbounded data inside one.

The deadline that is not honoured

The most important surprise in the lab is the last block. BLPOP on an empty list blocks until an element arrives or its timeout ends. The program ran it under a context with a 300ms deadline and a command timeout of 2 seconds:

  • With default options it returned redis: nil after 2.05s. go-redis did not stop when the context ended.
  • With redis.Options{ContextTimeoutEnabled: true} it returned read tcp ...: i/o timeout after 300ms.

By default the client does not apply the context's deadline to the socket, so a context.WithTimeout that you wrote to protect a request does nothing while a command is waiting on Redis. The cure is the option. Set it on every client, together with explicit DialTimeout, ReadTimeout and WriteTimeout, so no call can hang. The error you get after it fires is a network timeout, not context.DeadlineExceeded, so check both when you map errors to responses.

Blocking commands have another detail: the client's ReadTimeout must be longer than the command's own block time, or the socket times out first. go-redis adds the block time to the read deadline for BLPOP, XREAD and friends, so this normally works, but a custom ReadTimeout of 100ms on a client that blocks for seconds is a recipe for confusing errors.

The pool

The last line prints PoolStats(): hits=4137 misses=8 timeouts=0 total=8 idle=8. A hit is a command that found an idle connection, a miss opened a new one, and a timeout means a command waited for a connection and gave up. Eight misses match the eight goroutines that ran at once, and eight connections stayed open afterwards. The default pool size is 10 per CPU; set PoolSize for your concurrency, and export PoolStats as metrics: a rising Timeouts count means the pool is too small or Redis is too slow, the same signal as WaitCount for a SQL pool.

Other things to configure

  • MaxRetries defaults to 3 with backoff. Retried commands run again after a network error, so a non idempotent command (INCR, LPUSH) can be applied twice if the first reply was lost. Set it to zero for those clients, or accept at-least-once.
  • ConnMaxLifetime and ConnMaxIdleTime recycle connections, which matters behind load balancers and after failovers.
  • TLS is set with Options.TLSConfig, and a rediss:// URL passed to redis.ParseURL does the same.
  • Clusters and Sentinel have their own constructors (NewClusterClient, NewFailoverClient) with the same command API. Multi key commands in a cluster must use keys in one hash slot; hash tags such as {user:1}:cart pin them together.
  • Key design is the bigger decision: prefix keys by purpose and version (app:v2:session:abc), always set expirations on cache keys, and avoid very large values and the KEYS command, which blocks the server; use SCAN.

Data types worth knowing

Strings and hashes cover most cache work, and four other types solve problems that are awkward elsewhere.

Sorted sets (ZAdd, ZRangeWithScores, ZRevRank) keep members ordered by a score. A leaderboard is one ZADD per score update and one ZREVRANGE per page, both fast at millions of members. A delayed job queue is a sorted set scored by run time, polled with ZRANGEBYSCORE. Sets (SAdd, SIsMember, SInter) hold unique members: tags, unique visitors for a day, a deny list. Lists (LPush, BRPop) are simple queues, with the weakness that a consumer that pops and then crashes has lost the item, which is why streams are the better choice for work that must be done. HyperLogLog (PFAdd, PFCount) counts distinct items in 12KB of memory with about one percent error, the right tool for "how many unique visitors", where an exact count would need a set of every id.

Pick the type for the operation you need, not for the shape of the data. A rate limiter is a counter with an expiry. A session is a hash or a string of encoded data with an expiry. A feature flag set is a set. A queue of delayed work is a sorted set.

Handling errors

A Redis call fails in one of four ways, and each needs a different response. redis.Nil is a missing key, handled above. A network error or timeout means Redis is unreachable or slow: for a cache, fall back to the source and carry on; for a lock or a rate limiter, decide deliberately whether to fail open or closed. A redis.TxFailedErr is the optimistic transaction conflict, retried. A server error such as WRONGTYPE Operation against a key holding the wrong kind of value or OOM command not allowed when used memory > 'maxmemory' is a bug or a capacity problem and should surface as an alert, not a retry.

Do not let Redis be a single point of failure for reads that have a fallback. A cache that takes down the service when it is down is worse than no cache. Wrap calls in a short timeout, treat every error except redis.Nil as a miss, count the errors in a metric and add a circuit breaker if the cache is slow rather than down: a hundred requests each waiting 500ms on a timeout are an outage of their own.

Testing code that uses Redis

Two approaches work and they test different things. An in-process fake such as miniredis runs a subset of Redis commands inside the test binary, which makes tests fast and needs no container; testing Redis code with miniredis shows how, and where it stops (no real expiry timing under load, no cluster, a subset of Lua). A real Redis in a container, as this lab uses, tests everything including scripts, WATCH and the exact error values, and costs a second or two to start. Use the fake for the bulk of unit tests and a real server for the few tests that depend on server behaviour, such as a Lua script or a pipeline's error handling. Always flush or use a unique key prefix per test so tests do not see each other's data, and never run a test suite against a Redis that holds real data.

Security and operations

Redis has no authentication by default on a fresh install, and one that is reachable from outside the machine has been used to run attacker code more than once. Bind it to a private interface, require a password or, better, ACL users (Options.Username and Password) with only the commands each service needs, and use TLS across any untrusted network. Set maxmemory and an eviction policy that matches the use: allkeys-lru for a pure cache, noeviction for data you cannot lose, so that a full Redis returns errors you can see instead of silently discarding keys. Watch memory, evictions, connected clients and the slow log, and keep persistent data out of an instance configured for eviction.

When Redis is the wrong tool

Redis keeps data in memory and, depending on configuration, loses the most recent writes on a crash. It is a cache, a counter store and a coordination tool, not your system of record unless you have chosen its durability settings deliberately. Do not store anything there you cannot rebuild. The commands are documented in the Redis command reference and the client in the go-redis documentation, and the package API is on pkg.go.dev.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.