The whiteboard in the kitchen
You built a profile page. It loads fast when the cache is warm. Then a marketing campaign hits. The database CPU spikes to 100%. The page times out. You need a layer between your app and the database that holds hot data in RAM and serves it instantly. Memcached is that layer. It's a key-value store that lives in memory, designed to be simple, fast, and distributed.
Think of Memcached like a whiteboard in a busy kitchen. The chefs (your Go app) need ingredients. The pantry (the database) is in the basement and takes time to walk to. The whiteboard holds the most common ingredients right at hand. If the whiteboard has the data, the chef grabs it and cooks. If not, the chef runs to the pantry, grabs the item, writes it on the whiteboard for next time, and then cooks. Memcached is the whiteboard. It stores strings against keys. It expires data automatically. It doesn't care about schemas or relationships. It just gives you back what you put in, or tells you it's gone.
Minimal example
Here's the bare minimum to connect, set a value, and get it back. The library github.com/bradfitz/gomemcache/memcache is the standard choice. It uses the binary protocol by default, which is faster and more secure than the text protocol.
package main
import (
"fmt"
"log"
"github.com/bradfitz/gomemcache/memcache"
)
func main() {
// Connect to the server. The client manages a connection pool internally.
client := memcache.New("localhost:11211")
// Set a key with a value. Expiration is in seconds; 0 means no expiration.
err := client.Set(&memcache.Item{
Key: "user:101:name",
Value: []byte("Alice"),
Expiration: 300, // 5 minutes
})
if err != nil {
log.Fatal(err)
}
// Retrieve the item. Get returns an error if the key is missing.
item, err := client.Get("user:101:name")
if err != nil {
log.Fatal(err)
}
// Values are always []byte. Convert to string for display.
fmt.Println(string(item.Value))
}
The memcache.New call creates a client object. It doesn't open a connection immediately. The library opens connections lazily and reuses them. When you call Set, the client picks a server, sends the command, and returns. The Item struct holds the key, the value as bytes, and the expiration time. Memcached treats everything as a byte slice. You must marshal structs to JSON or protobuf before storing them. The Get call returns the Item or an error. If the key doesn't exist, the error is memcache.ErrCacheMiss. This is a sentinel error you can check directly.
Memcached is dumb. Your code must be smart about keys and expiration.
Realistic usage: the cache-aside pattern
In production, you rarely call Memcached directly in main. You wrap it in a service or use it inside an HTTP handler. The cache-aside pattern is the standard approach: check the cache, fetch from the database on a miss, write back to the cache, then return the result.
Here's the handler logic. It checks the cache, returns on a hit, and falls through on a miss.
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/bradfitz/gomemcache/memcache"
)
// User represents the domain object.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
}
// GetUserHandler demonstrates the cache-aside pattern.
// Handler retrieves a user from cache or falls back to a database simulation.
func GetUserHandler(client *memcache.Client, db *Database) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
cacheKey := fmt.Sprintf("user:%s", id)
// Check cache first. ErrCacheMiss is expected on a miss.
item, err := client.Get(cacheKey)
if err == nil {
// Cache hit. Decode the JSON bytes back into a struct.
var user User
json.Unmarshal(item.Value, &user)
w.Header().Set("X-Cache", "HIT")
json.NewEncoder(w).Encode(user)
return
}
// Cache miss. Fetch from the database.
user, err := db.FindByID(id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Marshal to JSON. Discard the error since JSON marshaling a struct rarely fails.
data, _ := json.Marshal(user)
// Write back to cache. Expiration prevents stale data forever.
client.Set(&memcache.Item{
Key: cacheKey,
Value: data,
Expiration: 600,
})
w.Header().Set("X-Cache", "MISS")
json.NewEncoder(w).Encode(user)
}
}
The underscore discards the error intentionally. JSON marshaling a plain struct with standard types won't fail, so checking the error adds noise without value. Use _ sparingly, but this is a valid case. The convention if err != nil { return err } is verbose by design. In the handler, checking err after Set is crucial. The boilerplate makes the failure visible. Don't swallow the error with _ on Set. If Set fails, the data never lands in the cache, and your app serves stale data silently.
Accept interfaces, return structs. Wrap the *memcache.Client in an interface for your service layer. This lets you swap in a mock client during tests. Define type Cache interface { Get(key string) ([]byte, error); Set(item *Item) error }. Your handler depends on the interface, not the concrete client. This keeps your code testable and flexible.
Cache invalidation is the hardest problem in computer science. Set expirations and move on.
Pitfalls and errors
Memcached errors are runtime issues, not compile-time. The compiler won't stop you from sending a huge value. Memcached has a default limit of 1MB per item. If you try to store more, the server rejects it. The client returns an error like memcache: server error: ERROR. You must check the error from Set.
Another pitfall is serialization. Memcached stores []byte. If you store a struct directly by casting, you get memory addresses, not data. You must use encoding/json or encoding/gob. If you forget to marshal, the cache holds garbage.
Connection handling is automatic. The client reconnects if the server drops. You don't need to manage connections manually. However, if the server is down, Get returns an error immediately. Your code must handle memcache.ErrNoServers or generic network errors.
If you pass a key that's too long, the server rejects it. The client returns an error containing SERVER_ERROR. Keys must be under 250 bytes. If you forget to check the error from Set, the data never lands in the cache. The worst cache bug is the one that never logs.
The standard gomemcache client doesn't accept context.Context. If you need cancellation, you must manage timeouts manually or wrap the client. This is a known gap in the library. Context is plumbing. Run it through every long-lived call site, even if the underlying library doesn't support it.
Advanced patterns
Keys need structure. Use namespaces to avoid collisions. api:user:101 is better than user:101. If you have multiple services sharing a Memcached cluster, prefix keys with the service name. This prevents one service from evicting another's data.
Use Add when you want to ensure you don't overwrite data written by another process. Set overwrites unconditionally. Add fails if the key already exists. This is useful for simple distributed locks, though Memcached isn't a lock manager. If Add returns memcache.ErrCacheMiss, the key exists and someone else holds the lock.
Use GetMulti when you need to fetch multiple items. Fetching one item at a time in a loop causes network latency. GetMulti fetches multiple keys in one round trip.
// Fetch multiple users in one network call.
keys := []string{"user:101", "user:102", "user:103"}
items, err := client.GetMulti(keys)
if err != nil {
log.Fatal(err)
}
// items is a map[string]*Item. Missing keys are absent from the map.
for k, v := range items {
fmt.Printf("%s: %s\n", k, v.Value)
}
The client is safe for concurrent use. Multiple goroutines can call Get and Set on the same client instance. The connection pool handles synchronization. You don't need to wrap the client in a mutex.
Run gofmt on your code. The community expects standard formatting. Don't argue about indentation. Most editors run it on save. Trust gofmt. Argue logic, not formatting.
Decision matrix
Use Memcached when you need a fast, distributed cache for read-heavy workloads. Use Redis when you need data structures like lists, sets, or pub/sub, or when you require persistence. Use a local in-memory map when you have a single process and don't need shared state across instances. Use the database directly when the data changes frequently and cache invalidation is too complex to manage.