How to Write a Generic Cache in Go

Implement a thread-safe generic cache in Go using sync.Map or a mutex-protected map with type parameters.

The map panic that kills your service

You deploy a Go service that caches database results. You use a plain map[string]Result. It works perfectly in your tests. You deploy to production, hit the endpoint from two browsers at once, and the server crashes with a fatal error. The map panicked because two goroutines touched it simultaneously. Go maps are not thread-safe. You need a cache that handles concurrency without dropping requests.

Shared state needs protection

A cache is shared state. Multiple goroutines read and write the same data. Go's concurrency model relies on communication, but sometimes shared state is the right tool. When you share data, you must protect it. The race detector will catch simple mistakes during testing, but it doesn't fix them. You need synchronization primitives.

Go offers two main approaches for a thread-safe cache. The first is a standard map guarded by a sync.RWMutex. This gives you full control and predictable performance. The second is sync.Map, a specialized type optimized for specific concurrency patterns. Most developers reach for sync.Map too early. It shines when keys are written once and read many times, or when different goroutines operate on distinct key sets. For a general-purpose cache with frequent updates, a mutex-protected map is faster and simpler.

Shared state demands a lock. Pick the right one.

Why generics matter here

Before Go 1.18, you wrote a cache using interface{}. Every Get returned an empty interface. You had to assert the type manually. If you asserted the wrong type, the program panicked at runtime.

Generics move that check to compile time. You write one Cache[K, V] and the compiler generates type-safe versions for every type pair you use. No assertions. No runtime panics on type mismatches. The compiler enforces the constraints, so you know the types are correct before the code runs.

Generics remove the copy-paste. Mutexes remove the panic.

Minimal generic cache

Here's the generic cache skeleton. It uses a read-write mutex to allow concurrent reads while serializing writes.

// Cache holds a thread-safe map of keys to values.
type Cache[K comparable, V any] struct {
    mu    sync.RWMutex
    // protects the map; allows concurrent reads or exclusive writes
    items map[K]V
    // stores data; K must be comparable for map keys
}

// NewCache creates a cache with the given capacity.
func NewCache[K comparable, V any](capacity int) *Cache[K, V] {
    return &Cache[K, V]{
        items: make(map[K]V, capacity),
        // initializes map with capacity to reduce resizing overhead
    }
}

The type parameters K and V define the key and value types. K comparable is a constraint. Map keys must be comparable. You can't use a slice or map as a key because the compiler can't check equality. V any means the value can be anything. The RWMutex is the workhorse. When you call Get, you take a read lock. If another goroutine is reading, you both proceed. If a writer is active, you wait. When you call Set, you take a write lock. All readers and writers block until you finish.

// Get retrieves a value by key.
func (c *Cache[K, V]) Get(key K) (V, bool) {
    c.mu.RLock()
    // RLock allows multiple goroutines to read simultaneously
    defer c.mu.RUnlock()
    // defer ensures unlock happens even if the function panics
    val, ok := c.items[key]
    return val, ok
}

// Set adds or updates a value.
func (c *Cache[K, V]) Set(key K, value V) {
    c.mu.Lock()
    // Lock blocks all other readers and writers until the update finishes
    defer c.mu.Unlock()
    c.items[key] = value
}

// Delete removes a key.
func (c *Cache[K, V]) Delete(key K) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.items, key)
    // delete is safe even if the key doesn't exist
}

Notice the receiver is (c *Cache[K, V]). Go convention dictates receiver names should be short, usually one or two letters matching the type. Use c for Cache, m for Map, b for Buffer. Never use this or self. Those are Java and Python habits that don't belong in Go.

Trust the type system. The compiler enforces the constraints.

Realistic cache with loading

A cache that just stores values is boring. Real caches load data on miss. You need a GetOrLoad method. This introduces a subtle bug: double-fetching. If two requests miss the cache at the same time, both might call the loader. You need to handle that.

Here's a cache with a GetOrLoad method. It takes a function to fetch the value if the key is missing. This pattern is common for database lookups or API calls.

// GetOrLoad returns the cached value or loads it using the provided function.
func (c *Cache[K, V]) GetOrLoad(key K, loader func() (V, error)) (V, error) {
    // Check read path first for speed.
    c.mu.RLock()
    // fast path: check if key exists without blocking writers
    val, ok := c.items[key]
    c.mu.RUnlock()
    if ok {
        return val, nil
    }

    // Lock for write to prevent double-loading.
    c.mu.Lock()
    // exclusive lock ensures only one goroutine calls the loader
    defer c.mu.Unlock()

    // Double-check inside the write lock.
    if val, ok := c.items[key]; ok {
        // another goroutine might have loaded the value while we waited for the lock
        return val, nil
    }

    val, err := loader()
    if err != nil {
        var zero V
        // zero value returned on error; avoids untyped nil issues
        return zero, err
    }
    c.items[key] = val
    return val, nil
}

The double-check is critical. Between the read lock and the write lock, another goroutine might have acquired the write lock, loaded the value, and released the lock. If you skip the second check, you load the value twice. The loader is expensive; don't run it twice.

If your loader function makes network calls, it should accept a context.Context. The context carries cancellation signals. If the HTTP request is cancelled, the loader should stop. Pass ctx as the first argument to the loader. Functions that take a context should respect cancellation and deadlines. This prevents goroutines from hanging after the client disconnects.

The function returns (V, error). Go doesn't have exceptions. Errors are values. You check them immediately. The boilerplate if err != nil is verbose by design. It forces you to acknowledge failure paths. Don't wrap errors in a custom type unless you need to add context. Just return the error.

The double-check is the only way to prevent duplicate work.

Pitfalls and compiler errors

The constraint K comparable is not just syntax. It tells the compiler that keys support equality checks. Maps rely on hashing and equality to find buckets. If you pass a slice as K, the compiler rejects it. Slices are reference types; two slices with the same content are not equal. You get invalid operation: map key type []int is not comparable. This error saves you from subtle bugs where a cache lookup fails because the key is a new slice instance.

sync.Map is a specialist. It avoids lock contention in specific cases, but it has overhead. It uses atomic operations and internal sharding. If you update the same keys frequently, sync.Map performs worse than a mutex-protected map. It also forces type assertions. Load returns any. You have to assert v.(V). If the type is wrong, you panic. Generics eliminate that risk.

Caches hold memory. If you insert keys and never delete them, the cache grows until the process runs out of memory. A generic cache doesn't know how to evict items. You need to add logic for size limits or time-to-live. For a simple cache, this means the caller must manage eviction, or you accept that the cache is bounded by available RAM. In long-running services, unbounded caches are a common cause of out-of-memory crashes. Always plan for eviction if the key space is unbounded.

When you call Get, you get a value and a boolean. If you don't care about the boolean, you can discard it with _. val, _ := cache.Get(key). This tells the compiler you intentionally ignored the second return value. Use this sparingly. Ignoring errors with _ is dangerous. Ignoring a "found" flag is usually fine.

sync.Map is a specialist. Don't use it as a general-purpose drop-in.

When to use what

Use a map protected by sync.RWMutex when you need a generic cache with frequent reads and occasional writes. This is the standard pattern for most applications.

Use sync.Map when your workload has distinct key sets per goroutine or keys are written once and read many times. It avoids lock contention in those specific cases.

Use a plain map without synchronization when the cache lives in a single goroutine, like inside a per-request context or a background worker. Adding a mutex adds overhead you don't need.

Use a specialized library like ristretto or bigcache when you need eviction policies, TTLs, or high-performance sharding. Writing a production-grade cache with eviction is hard.

Start with a mutex-protected map. Measure before optimizing.

Where to go next