The connection pool is the real client
You built a leaderboard feature. It works fine with ten users. Now you have ten thousand, and the database query takes three seconds. You decide to offload the heavy lifting to Redis. You install the client, write the connection code, and run it. The program crashes with a connection refused error, or worse, it hangs silently because you forgot to handle the context timeout.
Connecting to Redis in Go isn't just about passing an address. It involves managing a client lifecycle, handling errors explicitly, and keeping the connection pool healthy under load. The library handles the protocol. You handle the configuration and the error paths.
Redis speaks TCP. Go speaks pools.
Redis lives in memory and responds to commands over a TCP socket. Your Go program doesn't talk to Redis directly. You use a client library that wraps the protocol details and manages a pool of connections.
The pool is the key concept. Opening a TCP connection costs time. If you open a new connection for every request, your latency spikes. The client keeps a set of open connections ready to go. When you call a command, the client grabs a connection from the pool, sends the command, gets the reply, and returns the connection to the pool. You don't manage the connections manually. The client does.
The pool balances latency and resource usage. A pool that is too small causes requests to block waiting for a connection. A pool that is too large wastes memory and may overwhelm the Redis server. The client library provides defaults that work for most workloads, but you should tune the pool size based on your traffic and the server's maxclients setting.
Minimal connection check
Here's the simplest way to verify a connection. You create a client with the address, then ping the server to confirm it's alive.
package main
import (
"context"
"log"
"github.com/redis/go-redis/v9"
)
func main() {
// NewClient configures the connection pool and protocol settings.
// It does not open a connection yet.
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379", // Host and port of the Redis server.
})
// Context carries cancellation and deadlines.
// Always pass a context to Redis calls.
ctx := context.Background()
// Ping sends a command to verify connectivity.
// Result() blocks until the reply arrives or the context expires.
_, err := rdb.Ping(ctx).Result()
if err != nil {
log.Fatal(err)
}
}
What happens under the hood
When you call redis.NewClient, the library creates a Client struct. This struct holds the configuration and an internal connection pool. No network traffic happens yet. The pool is lazy.
When you call rdb.Ping(ctx), the client checks the pool for an available connection. If one exists, it borrows it. If not, it creates a new TCP connection up to the pool limit. The client serializes the PING command into the Redis protocol format and writes it to the socket. The server replies. The client deserializes the reply and returns it. The connection goes back to the pool.
If the context times out during this process, the client cancels the operation and returns an error. The pool handles broken connections automatically by retrying or replacing them. You don't need to detect network drops manually. The client retries transient errors based on the command type.
Ping verifies the link. The pool does the heavy lifting.
Realistic service with error handling
In production code, you wrap the client in a service struct. This keeps the connection pool shared across requests and isolates the Redis logic from your application code.
// CacheService wraps the Redis client for application logic.
// Sharing the client reuses the connection pool across all calls.
type CacheService struct {
rdb *redis.Client
}
// NewCacheService initializes the service with connection settings.
func NewCacheService(addr string) *CacheService {
return &CacheService{
rdb: redis.NewClient(&redis.Options{
Addr: addr,
PoolSize: 10, // Max concurrent connections to the server.
}),
}
}
The receiver name s follows Go convention. It is short and matches the type. The community expects one or two letter receivers. Don't use this or self.
Here's how you fetch a value and handle the result correctly.
// Get fetches a string value from the cache.
// It distinguishes between a missing key and a network error.
func (s *CacheService) Get(ctx context.Context, key string) (string, error) {
// Get queues the command.
// Result() executes it and returns the value.
res := s.rdb.Get(ctx, key)
// Check errors explicitly.
// redis.Nil indicates the key does not exist.
if err := res.Err(); err != nil {
if err == redis.Nil {
return "", nil
}
return "", err
}
return res.String()
}
The context.Context parameter goes first. This is a Go convention. It makes the context easy to spot and allows middleware to inject deadlines. The function checks res.Err() before extracting the value. This pattern separates the command object from the result.
Treat redis.Nil as a cache miss, not a failure.
The command interface pattern
The library uses a command interface. Methods like Get return a Cmd object, not the value. This design supports pipelines. You can chain commands without executing them immediately. The Cmd object holds the arguments and the pending result.
When you call Result(), the client sends the command and populates the result. This separation allows the library to batch commands efficiently. If you try to use the Cmd object as the value, the compiler rejects the program with cannot use res (variable of type *redis.Cmd) as string value in return statement. You must call Result() or String() to extract the data.
This pattern also enables type safety. The Cmd interface provides methods like String(), Int(), and Bool(). The library validates the type when you call these methods. If the server returns a value that doesn't match the expected type, the method returns an error.
Context and timeouts
Always pass a context with a timeout. If you pass context.Background() to a long-running call and the client hangs, your goroutine leaks. The connection stays borrowed from the pool. Eventually, the pool exhausts, and your application stops responding.
Use context.WithTimeout to set a deadline. This prevents the goroutine from hanging indefinitely.
import "time"
// GetWithTimeout fetches a value with a deadline.
// If the server does not respond in time, the call fails fast.
func (s *CacheService) GetWithTimeout(ctx context.Context, key string) (string, error) {
// Create a context with a 500ms deadline.
// This prevents the goroutine from hanging indefinitely.
timeoutCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel() // Release resources when the function returns.
res := s.rdb.Get(timeoutCtx, key)
if err := res.Err(); err != nil {
if err == redis.Nil {
return "", nil
}
return "", err
}
return res.String()
}
The defer cancel() call ensures the context resources are released when the function returns. This is standard Go practice. The context is plumbing. Run it through every Redis call.
Pitfalls and compiler errors
The most common mistake is treating redis.Nil as a fatal error. When you call Get on a key that doesn't exist, the library returns a redis.Nil error. If you log this as a failure, your error logs fill up with noise. Check for redis.Nil specifically and treat it as a cache miss.
If you forget to call Result() on a command, the compiler rejects the program with cannot use res (variable of type *redis.Cmd) as string value in return statement. The command object is not the value. You must call Result() to get the data.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. In Redis, leaks happen when a goroutine holds a connection from the pool and never releases it. This occurs when you forget to pass a context with a timeout or when you ignore the context cancellation. Always respect the context. If the context is cancelled, the client returns an error and releases the connection.
Pool exhaustion is another risk. If your pool size is too small, requests block waiting for a connection. If it's too large, Redis might reject connections. Monitor the pool stats. The client provides Stats() to show active and idle connections. Tune the pool size based on your QPS and the server's limits.
The worst goroutine bug is the one that never logs.
Decision matrix
Use go-redis when you want a modern, type-safe API with built-in pipeline support and automatic retry logic.
Use redigo when you need a lightweight client with minimal dependencies and are comfortable with a lower-level interface.
Use the cluster client when your Redis deployment spans multiple nodes and you need automatic sharding and failover.
Use a connection pool wrapper when you need to share a single client across many packages without passing the client through every function signature.
Use raw TCP sockets only when you are implementing a custom protocol extension that the client library does not support.