You're going to write a Redis-compatible server in Go. Not a Redis clone — a teaching toy that nails the bones: the RESP wire protocol, the SET/GET/DEL/KEYS/PING command surface, mutex-protected concurrent connections, and persistence via RDB snapshot + AOF log. By the end you'll be able to point redis-cli at it and it'll just work.
This is the longest project in the catalog. Pace yourself. Each milestone is a working artefact you can stop at — v1 round-trips a PING; v2 actually stores values; v3 survives restarts and concurrent clients.
Before you start
brew install go for Go itself; brew install redis ships redis-cli alongside the (full) Redis server. We won't run real Redis — but redis-cli is the easiest way to talk to your toy.
The walkthrough
Make the project + Go module
In your terminal, make a project folder and initialise a Go module:
mkdir mini-redis && cd mini-redis
go mod init mini-redis
mkdir cmd internal
touch cmd/server/main.go
cmd/server/main.go is where the entrypoint lives; we'll put RESP + store + persistence under internal/. That layout matches Go convention — anything in internal/ can't be imported by other modules, which is exactly right for a toy.
module mini-redis go 1.22
A TCP listener + accept loop
In cmd/server/main.go, listen on port 6380 (one off from real Redis' 6379 so you can run both side-by-side) and accept connections in a loop. For now, every connection just gets logged + closed.
mini-redis listening on :6380 accepted connection from 127.0.0.1:54321
Define the RESP value type
Create internal/resp/value.go. RESP is five primitive types. Encode them as one tagged union (Go-flavoured: a struct with a kind discriminator + per-kind fields).
+OK\r\n SimpleString
-ERR something\r\n Error
:42\r\n Integer
$5\r\nhello\r\n BulkString (length-prefixed; nil = $-1\r\n)
*2\r\n$3\r\nfoo\r\n... Array
package resp
type Kind int
const (
KindSimpleString Kind = iota
KindError
KindInteger
KindBulkString
KindArray
)
type Value struct {
Kind Kind
Str string
Int int64
Bulk []byte
IsNull bool
Array []Value
}
Read RESP from a `bufio.Reader`
In internal/resp/parser.go, write Parse(r *bufio.Reader) (Value, error). Read the first byte to learn the type, then dispatch.
array of length 2: PING hello
Encode RESP to a `bufio.Writer`
Add func Write(w *bufio.Writer, v Value) error to the same package. Mirror the parser exactly. After writing, the caller must w.Flush().
func Write(w *bufio.Writer, v Value) error {
switch v.Kind {
case KindSimpleString:
fmt.Fprintf(w, "+%s
", v.Str)
case KindError:
fmt.Fprintf(w, "-%s
", v.Str)
case KindInteger:
fmt.Fprintf(w, ":%d
", v.Int)
case KindBulkString:
if v.IsNull {
fmt.Fprint(w, "$-1
")
} else {
fmt.Fprintf(w, "$%d
", len(v.Bulk))
w.Write(v.Bulk)
fmt.Fprint(w, "
")
}
case KindArray:
fmt.Fprintf(w, "*%d
", len(v.Array))
for _, item := range v.Array {
if err := Write(w, item); err != nil {
return err
}
}
}
return nil
}
Per-connection goroutine: read frame, dispatch, write reply
Back in main.go, replace the "log + close" placeholder with a per-connection goroutine that reads RESP frames in a loop and replies. For now, the dispatch is a stub that returns +PONG\r\n for everything.
# in another terminal $ redis-cli -p 6380 PING PONG $ redis-cli -p 6380 PING hello PONG
The Store type — a map plus a method API
Create internal/store/store.go. Plain map[string]string for now (we'll add a mutex in milestone 3). Methods: Set, Get, Del, Keys.
package store
import "path/filepath"
type Store struct {
data map[string]string
}
func New() *Store {
return &Store{data: make(map[string]string)}
}
func (s *Store) Set(key, value string) {
s.data[key] = value
}
func (s *Store) Get(key string) (string, bool) {
v, ok := s.data[key]
return v, ok
}
func (s *Store) Del(keys ...string) int {
n := 0
for _, k := range keys {
if _, ok := s.data[k]; ok {
delete(s.data, k)
n++
}
}
return n
}
func (s *Store) Keys(pattern string) []string {
out := []string{}
for k := range s.data {
matched, _ := filepath.Match(pattern, k)
if matched {
out = append(out, k)
}
}
return out
}
Implement SET
In cmd/server/main.go, replace the +PONG stub with a dispatch(v Value, store *Store) Value function. Start with SET. Argument shape: *3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n — an array whose first element is the command name (case-insensitive), followed by args.
$ redis-cli -p 6380 SET name alice OK
Implement GET
Add a case "GET": arm. Validate argument count, look up the key, return either a bulk string or the nil bulk ($-1\r\n).
$ redis-cli -p 6380 SET name alice OK $ redis-cli -p 6380 GET name "alice" $ redis-cli -p 6380 GET missing (nil)
Implement DEL (variadic) — return the count deleted
DEL key [key ...] accepts ≥ 1 key. Return an Integer with the count of keys actually removed (a missing key counts as 0, not as an error).
$ redis-cli -p 6380 SET a 1 OK $ redis-cli -p 6380 SET b 2 OK $ redis-cli -p 6380 DEL a b c (integer) 2
Implement KEYS (glob match) — return an array of matches
KEYS * returns every key. KEYS user:* returns every key starting with user:. Use the filepath.Match pattern matcher you already wired up.
$ redis-cli -p 6380 SET user:1 alice OK $ redis-cli -p 6380 SET user:2 bob OK $ redis-cli -p 6380 SET other 99 OK $ redis-cli -p 6380 KEYS user:* 1) "user:1" 2) "user:2"
Implement PING [msg] — returns PONG or echoes the message
PING → +PONG\r\n. PING hello → $5\r\nhello\r\n (a bulk string echo). One arg max.
$ redis-cli -p 6380 PING PONG $ redis-cli -p 6380 PING hello "hello"
Why does PING with no argument return SimpleString +PONG\\r\\n but PING hello return a BulkString?
Wrap the store with sync.RWMutex
Two clients writing the same map at the same time = data race + crash. Add an sync.RWMutex. Reads (Get, Keys) take RLock; writes (Set, Del) take Lock. Run go test -race ./... to confirm it's clean.
# in two terminals at once $ for i in $(seq 1 1000); do redis-cli -p 6380 SET "k$i" "$i"; done $ for i in $(seq 1 1000); do redis-cli -p 6380 GET "k$i"; done # both finish without server crash; final KEYS '*' shows 1000 entries.
Periodic RDB snapshot — full-state dump to a file
Add func (s *Store) Snapshot(path string) error that writes the entire map to disk via encoding/gob. In main, kick off a goroutine that calls it every 10 seconds.
func (s *Store) Snapshot(path string) error {
s.mu.RLock()
defer s.mu.RUnlock()
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
defer f.Close()
if err := gob.NewEncoder(f).Encode(s.data); err != nil {
return err
}
if err := f.Sync(); err != nil {
return err
}
return os.Rename(tmp, path)
}
Load the RDB if it exists at boot
Add func (s *Store) LoadSnapshot(path string) error. In main, call it before the listener starts. Missing file is not an error; a load-failure on a present file is.
# After: SET name alice; (wait 11s for snapshot); restart server $ redis-cli -p 6380 GET name "alice"
Append-only log — every write replicated to disk; replayed on startup
The snapshot loses up to 10 seconds of writes on a crash. Add an append-only file (AOF) that records every mutating command (SET, DEL). On startup, replay the AOF AFTER loading the snapshot so we recover everything.
# After SET name alice; SET name bob; (no time for snapshot); kill -9 server $ redis-cli -p 6380 GET name "bob"
Integration tests — start the server in a goroutine, talk to it via net.Dial
Write cmd/server/main_test.go that boots the server in-process, opens a TCP connection from the test, and exercises SET → GET → DEL → KEYS. Use go test ./... to run.
$ go test ./... ok mini-redis/cmd/server 0.024s ok mini-redis/internal/resp 0.011s ok mini-redis/internal/store 0.018s
Why do we run the server in-process (in a goroutine) for the integration test, rather than spawning it as a subprocess?
Write the README — what works, what doesn't, how to run
End by writing README.md covering: what mini-redis implements, what it intentionally doesn't (EXPIRE, transactions, replication, RESP3), how to build (go build ./cmd/server), how to run (./server from the project dir), how to point redis-cli at it. Include a line about the on-disk files (dump.rdb, appendonly.aof) and how to reset (rm them).
# mini-redis A teaching toy: a Redis-compatible server in ~600 lines of Go. Speaks RESP, supports SET / GET / DEL / KEYS / PING, persists state via RDB snapshot + AOF log. ## What works - The 5 commands above - Concurrent clients (mutex-protected store) - Snapshot every 10s + AOF after every write - Replay on startup: snapshot first, then AOF tail ## What is intentionally out of scope - EXPIRE / TTL - MULTI / EXEC / WATCH - Pub/Sub - Replication - Cluster - RESP3 (we only do RESP2) - SCAN (we use KEYS, which is O(n)) ## Build + run ```bash go build -o server ./cmd/server ./server # listens on :6380 redis-cli -p 6380 PING # in another terminal ``` ## On-disk files - `dump.rdb` — RDB snapshot, written every 10s. - `appendonly.aof` — append-only command log, fsynced after every mutation. To reset state, delete both files before starting the server.
You did it
Run go build -o server ./cmd/server && ./server once more. In another terminal, redis-cli -p 6380 and play. You wrote a real wire-protocol server. The pattern — accept loop, per-connection goroutine, framed parser/writer, mutex-protected state, snapshot + AOF — is how you build any networked server worth running.
What you covered:
- TCP + the accept-loop pattern
- A binary-safe wire-format parser (RESP)
- Tagged-union design in idiomatic Go
sync.RWMutexand the rules for holding locks- The atomic-replace + fsync persistence patterns
- RDB-style full-state snapshot
- AOF-style append-only command log + replay
- In-process integration testing with
net.Dial