This episode teaches state and performance strategies: in-memory caching with sync.Map and golang-lru, Redis integration with go-redis, database schema management with golang-migrate, and state management best practices for Go services.

Every database query costs I/O, network, and CPU. For data that rarely changes, reading from the database over and over is wasteful. In episode 9 we solve this with caching, while keeping the database schema managed through migrations.
Episode 9 covers three things: in-memory caching with sync.Map and an LRU library, Redis integration with go-redis for distributed caching, and database schema management with golang-migrate. By the end of the episode you'll understand when state should live in the process, in Redis, or stay in the database.
sync.Map is the concurrency-safe map from the standard library, suited for cases where keys are written once and read many times. For light load in a single process, it's the simplest choice.
package main
import "sync"
type Cache struct {
data sync.Map
}
func (c *Cache) Set(kunci string, nilai string) {
c.data.Store(kunci, nilai)
}
func (c *Cache) Get(kunci string) (string, bool) {
v, ok := c.data.Load(kunci)
if !ok {
return "", false
}
return v.(string), true
}However, sync.Map has no expiry or size limit mechanism. For caches that need LRU and TTL, use a library like github.com/hashicorp/golang-lru/v2 — lru.New[string, string] provides generic type safety, keeps frequently accessed items, and evicts the oldest items when capacity is full.
An in-memory cache only lives while the process runs. When an application runs across many instances, each instance has its own cache, which can become inconsistent. The solution: Redis, a centralized cache shared by all instances.
go get github.com/redis/go-redis/v9package main
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
err := rdb.Set(ctx, "kunci", "nilai", 5*time.Minute).Err()
if err != nil {
panic(err)
}
hasil, err := rdb.Get(ctx, "kunci").Result()
if err != nil {
panic(err)
}
fmt.Println("dari redis:", hasil)
}Set with a 5-minute TTL ensures data doesn't go stale forever. Every go-redis operation accepts context.Context as its first argument.
The most common pattern for using Redis is cache-aside: read from the cache first; if it's empty, read from the database and then fill the cache:
func ambilPengguna(ctx context.Context, id int) (Pengguna, error) {
key := fmt.Sprintf("pengguna:%d", id)
if val, err := rdb.Get(ctx, key).Result(); err == nil {
var u Pengguna
json.Unmarshal([]byte(val), &u)
return u, nil
}
var u Pengguna
if err := db.QueryRowContext(ctx,
"SELECT id, nama FROM pengguna WHERE id = $1", id,
).Scan(&u.ID, &u.Nama); err != nil {
return Pengguna{}, err
}
data, _ := json.Marshal(u)
rdb.Set(ctx, key, data, 10*time.Minute)
return u, nil
}The cache-miss stampede (thundering herd) when many requests arrive simultaneously can be mitigated with singleflight or a lock. Invalidation is done by deleting the key when data changes, rather than updating the value directly.
The database schema evolves with application features: adding tables, columns, or indexes. Migrations make these changes versioned and reproducible in every environment. golang-migrate is the standard tool for this.
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latestEach migration consists of two files: up to apply and down to revert.
migrate create -ext sql -dir migrations -seq create_penggunaCREATE TABLE pengguna (
id BIGSERIAL PRIMARY KEY,
nama TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
dibuat_pada TIMESTAMPTZ DEFAULT now()
);Run the migration with a terminal command:
migrate -path migrations -database "postgres://user:pass@localhost:5432/app" upmigrate down 1 reverts the last migration. Use migrations as the first step in the deployment pipeline before the new application is released, and always test migrations in staging first. The migrate -version command shows the currently active migration version in the database.
Some principles for state in Go services:
With these principles, you can add caching to a service without creating new sources of inconsistency.
Episode 9 completed your state and performance strategy: in-memory caching with sync.Map and golang-lru, distributed caching with go-redis and the cache-aside pattern, versioned schema migrations with golang-migrate, and state management principles for Go services.
Key takeaways:
sync.Map for simple caching within a single process.golang-lru adds size limits and an eviction strategy.up and down migrations keep schemas consistent across environments.In the next episode we will discuss basic networking, HTTP servers, and REST APIs — building an HTTP server with net/http and handlers, routing with http.ServeMux in Go 1.22, middleware, modern routers like chi, gin, or echo, and route design best practices. This is when your Go application becomes a real service.