Redis as your application's short-term memory
Your database is reliable but slow. You query it for a user profile, and it takes 50 milliseconds. Fine once. Fine ten times. Now your app has a thousand users refreshing the page, and that 50 milliseconds multiplies into a bottleneck. You also need to remember who the user is between requests. Storing session data in memory leaks as the server restarts. Storing it in the database adds latency to every single request. Redis solves both problems by sitting between your app and the database as a blazing-fast key-value store that lives in RAM.
Think of Redis as a giant whiteboard in the hallway of your application. The database is the filing cabinet in the back office. Walking to the cabinet takes time. Writing on the whiteboard takes a fraction of a second. You put the things everyone needs to see constantly on the whiteboard. You also put sticky notes on the whiteboard that say "This note expires in 30 minutes." When the time is up, the note disappears automatically. That expiration is the key to sessions and cache invalidation. You don't write cleanup code. The store handles it.
The core loop: set, get, expire
Here's the simplest interaction with Redis: create a client, set a value with a lifetime, try to get it, and handle the case where it's gone.
package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
// Client manages a pool of connections to the Redis server.
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
DB: 0, // Use default database
})
// Set a key with a 1-minute TTL. Redis deletes it automatically after expiry.
err := rdb.Set(ctx, "greeting", "Hello from Redis", time.Minute).Err()
if err != nil {
panic(err)
}
// Get the value. Result returns the value and any error.
val, err := rdb.Get(ctx, "greeting").Result()
if err == redis.Nil {
fmt.Println("Key not found")
} else if err != nil {
panic(err)
} else {
fmt.Println("Got:", val)
}
}
The client initializes with redis.NewClient. You pass an Options struct with the address and database number. The client doesn't open a single connection. It creates a pool of connections that it reuses. This prevents the overhead of establishing a new TCP connection for every request. The default pool size scales with your CPU count. You rarely need to tune this unless you hit connection limits.
The Set method takes a context, a key, a value, and a duration. The duration is the time-to-live, or TTL. When you pass time.Minute, Redis schedules the key for deletion after 60 seconds. You don't need a background job to clean up old data. The server does it for you.
The Get method returns a Cmd object. You call .Result() to execute the command and retrieve the value. This pattern separates command construction from execution. It allows the library to batch commands later if you use pipelines. The result is a string and an error. If the key doesn't exist, the error is redis.Nil. If the network fails, the error is a standard Go error. You must check for redis.Nil explicitly. Treating a cache miss as a fatal error breaks your application.
Redis is fast. Your network is not. Always pass context.
Caching expensive queries
Caching follows a standard pattern called "cache-aside." You check the cache first. If the data is there, you return it. If it's missing, you fetch it from the database, store it in the cache, and then return it. This keeps your database load low and your response times high.
Here's a function that caches a user profile. It checks Redis, falls back to a simulated database call, and writes the result back to Redis with a TTL.
// getUserProfile checks the cache first, then falls back to the database.
func getUserProfile(ctx context.Context, rdb *redis.Client, userID string) (string, error) {
cacheKey := fmt.Sprintf("user:profile:%s", userID)
// Try to get the cached profile.
cached, err := rdb.Get(ctx, cacheKey).Result()
if err == redis.Nil {
// Cache miss: fetch from database.
profile := fetchFromDatabase(userID)
// Store in cache with 1-hour TTL to handle eventual consistency.
err = rdb.Set(ctx, cacheKey, profile, time.Hour).Err()
if err != nil {
// Log the error but return the data anyway.
// Cache write failures shouldn't block the user.
return profile, nil
}
return profile, nil
} else if err != nil {
return "", err
}
return cached, nil
}
// fetchFromDatabase simulates a slow database query.
func fetchFromDatabase(userID string) string {
return fmt.Sprintf("Profile data for %s from DB", userID)
}
The function constructs a cache key using fmt.Sprintf. The key includes the resource type and the ID. This prevents collisions. If you cache a user profile and a user avatar with the same ID, they need different keys. The prefix user:profile: makes the namespace clear.
When the cache misses, the function calls fetchFromDatabase. In real code, this is your database query. After getting the data, it calls Set with a TTL. The TTL is critical. Without it, the cache holds stale data forever. A one-hour TTL means the data refreshes periodically. If the user updates their profile, the cache eventually expires and pulls the new data. This is "eventual consistency." It's usually acceptable for profiles.
If the Set call fails, the function logs the error and returns the data anyway. Cache writes are best-effort. If Redis is down, the user still gets their profile from the database. You don't want a cache failure to take down your entire app. The convention here is clear: cache reads can fail fast. Cache writes degrade gracefully.
Cache misses are expected. Cache errors are not. Treat them differently.
Managing user sessions
Sessions work differently than caching. You store session data in Redis using a unique session ID as the key. The value is the serialized session state. You set a TTL to handle timeouts. When the user logs out or the session expires, the key disappears.
Here's how you save and load a session. The session data is a struct that gets marshaled to JSON. JSON is human-readable, which makes debugging easier. You can inspect the Redis store and see exactly what's stored.
type Session struct {
UserID string `json:"user_id"`
Role string `json:"role"`
LoggedIn time.Time `json:"logged_in"`
}
// saveSession serializes the session and stores it with a TTL.
func saveSession(ctx context.Context, rdb *redis.Client, sessionID string, s Session) error {
// Marshal struct to JSON bytes.
data, err := json.Marshal(s)
if err != nil {
return err
}
// Store with 30-minute TTL. Session expires if user is inactive.
return rdb.Set(ctx, sessionID, string(data), 30*time.Minute).Err()
}
// loadSession retrieves and deserializes the session.
func loadSession(ctx context.Context, rdb *redis.Client, sessionID string) (Session, error) {
var s Session
// Get the raw JSON string from Redis.
val, err := rdb.Get(ctx, sessionID).Result()
if err == redis.Nil {
return s, fmt.Errorf("session not found")
} else if err != nil {
return s, err
}
// Unmarshal JSON back into the struct.
err = json.Unmarshal([]byte(val), &s)
if err != nil {
return s, fmt.Errorf("failed to parse session: %w", err)
}
return s, nil
}
The Session struct uses json tags to control the field names in the JSON output. This keeps the keys consistent. The saveSession function marshals the struct to JSON. It then calls Set with the session ID as the key and a 30-minute TTL. The TTL acts as an inactivity timeout. If the user doesn't refresh the session, it expires automatically. You don't need a cron job to clean up old sessions.
The loadSession function retrieves the value. It checks for redis.Nil. If the key is missing, it returns a "session not found" error. This is how you handle logouts. If the session ID is invalid or expired, the user is logged out. If the key exists, it unmarshals the JSON back into the struct. If the JSON is malformed, it returns an error. This protects against corruption.
Security matters for sessions. The session ID must be cryptographically secure. Use crypto/rand to generate random bytes. Convert them to a hex string. If the ID is predictable, an attacker can hijack sessions. Never use sequential IDs or timestamps.
Convention aside: context.Context always goes as the first parameter. The community expects it. Functions that take a context should respect cancellation. If the request is cancelled, the Redis call should stop. go-redis checks the context before executing commands. If the context expires, the call returns context deadline exceeded. This prevents goroutines from hanging indefinitely.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and error handling
Redis is simple, but the integration has traps. The most common mistake is mishandling redis.Nil. The client returns this error when a key doesn't exist. If you treat it like a network error, your app crashes on every cache miss. You must check err == redis.Nil before checking err != nil. The order matters. redis.Nil is a specific sentinel error. It's not a generic error.
Another trap is forgetting TTLs. If you set a key without a duration, it lives forever. Redis fills up. You get OOM command not allowed when used memory > maxmemory from the server. Your writes start failing. Always set a TTL for cache and session keys. If you need permanent data, use the database. Redis is for ephemeral state.
Serialization errors happen when the data structure changes. If you add a field to your Session struct, old sessions in Redis might fail to unmarshal. JSON is forgiving with missing fields, but strict with types. If you change a field from string to int, unmarshal fails. You get json: cannot unmarshal string into Go struct field Session.UserID of type int. Handle this gracefully. Return a default session or force a re-login. Don't panic.
Connection issues are real. Redis is a separate service. It can go down. If the connection fails, go-redis returns an error like dial tcp 127.0.0.1:6379: connect: connection refused. Your code should handle this. For caching, fall back to the database. For sessions, treat it as a logout. Never let a Redis error crash your HTTP handler.
The worst goroutine bug is the one that never logs. Always check errors.
When to use Redis
Redis is powerful, but it's not always the right tool. Adding a dependency increases complexity. You need to manage the Redis server, monitor it, and handle failures. Use Redis when the benefits outweigh the cost.
Use Redis caching when you need to share data across multiple server instances and can tolerate occasional stale reads. Use Redis sessions when your application runs behind a load balancer and users must stay logged in regardless of which server handles their request. Use a local in-memory map when you have a single server instance and want the absolute lowest latency without network overhead. Use the database directly when data consistency is more important than speed and the read volume is low.
Shared state requires shared storage. Local state stays local.