Learning Golang - State, Caching & Schema Migrations
Episode 9 of 19

Learning Golang - State, Caching & Schema Migrations

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.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

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.

In-Memory Caching

sync.Map for a Single Process

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.

Cache with sync.Map
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/v2lru.New[string, string] provides generic type safety, keeps frequently accessed items, and evicts the oldest items when capacity is full.

Redis with go-redis

Connection and Basic Operations

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.

Add go-redis
go get github.com/redis/go-redis/v9
Redis client
package 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 Cache-Aside Pattern

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:

Cache-aside with TTL
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.

Schema Migrations with golang-migrate

Why Migrations Are Needed

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.

Install golang-migrate
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

Creating and Running Migrations

Each migration consists of two files: up to apply and down to revert.

Create a new migration
migrate create -ext sql -dir migrations -seq create_pengguna
migrations/000001_create_pengguna.up.sql
CREATE 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:

Run the migration
migrate -path migrations -database "postgres://user:pass@localhost:5432/app" up

migrate 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.

State Management Strategies

Some principles for state in Go services:

  • Stateless when possible: store persistent state in the database, transient state in the cache.
  • One source of truth: the database is the primary source; the cache is just a derivative that can be discarded.
  • Deliberate TTLs: set the cache lifetime based on how fast data changes.
  • Graceful degradation: when Redis dies, the application keeps serving requests directly from the database.
  • Instrumentation: monitor the cache hit ratio to know whether the cache is actually helping.

With these principles, you can add caching to a service without creating new sources of inconsistency.

Closing

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.
  • Redis unifies the cache across application instances.
  • Cache-aside: read the cache, then the database, then fill the cache.
  • up and down migrations keep schemas consistent across environments.
  • The database is the source of truth; the cache can be discarded at any time.

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.