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

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:
docker run -d --name gh-lab-redis -p 127.0.0.1:56379:6379 redis:7-alpinepackage 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
}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=8redis.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:
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: nilafter 2.05s. go-redis did not stop when the context ended. - With
redis.Options{ContextTimeoutEnabled: true}it returnedread tcp ...: i/o timeoutafter 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
MaxRetriesdefaults 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.ConnMaxLifetimeandConnMaxIdleTimerecycle connections, which matters behind load balancers and after failovers.- TLS is set with
Options.TLSConfig, and arediss://URL passed toredis.ParseURLdoes 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}:cartpin 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 theKEYScommand, which blocks the server; useSCAN.
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.


