Redis
Building A Minimal Redis RESP2 Server In Go
Redis feels almost magical when you first use it. You connect to a TCP port,
send a command like SET name redis, and it replies immediately. Under that
speed is a protocol that is refreshingly small: RESP, the Redis Serialization
Protocol.
This project implements a minimal RESP2 parser, writer, and tiny Redis-like TCP
server in Go. It is not a Redis clone. It does not implement persistence,
expiration, replication, pub/sub, clustering, transactions, or RESP3. The goal
is narrower and more useful for learning: understand how Redis clients and
servers exchange bytes on the wire.
By the end, we have a server that can handle:
PING
ECHO hello
SET name redis
GET name
And we can talk to it with the real Redis CLI:
go run ./cmd/resp-server
redis-cli -p 6380 PING
The interesting part is not the key-value map. The interesting part is how a
plain TCP stream becomes typed protocol values.
RESP In One Idea
RESP is built around one simple rule:
The first byte tells you how to parse the rest of the value.
RESP2 has five common frame types:
+OK\r\n simple string
-ERR unknown command\r\n error
:123\r\n integer
$5\r\nhello\r\n bulk string
*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n array
The prefix byte is the type tag:
| Prefix | Type | Example |
| --- | --- | --- |
| + | Simple string | +OK\r\n |
| - | Error | -ERR bad command\r\n |
| : | Integer | :42\r\n |
| $ | Bulk string | $5\r\nhello\r\n |
| * | Array | *1\r\n$4\r\nPING\r\n |
Every line ends with CRLF, meaning the exact two bytes \r\n. This matters.
RESP is not "newline-ish"; it is specifically CRLF-framed.
Redis Commands Are Arrays
When you type this in redis-cli:
ECHO hello
The client does not send the plain text line ECHO hello. It sends a RESP
array containing bulk strings:
*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n
Expanded for readability:
*2\r\n
$4\r\n
ECHO\r\n
$5\r\n
hello\r\n
That means:
-
*2says "this is an array with two items" -
$4says "the next bulk string payload is 4 bytes" -
ECHOis the first payload -
$5says "the next bulk string payload is 5 bytes" -
hellois the second payload
This representation is why Redis commands can safely contain spaces, newlines,
or binary data inside arguments. The server does not guess where an argument
ends. The length tells it.
The Value Model
The Go package starts with a small Value type:
type Value struct {
Kind Kind
String string
Int int64
Array []Value
Null bool
}
This is intentionally plain. A simple string, error, and bulk string all use the
String field. Integers use Int. Arrays use Array. Null bulk strings and
null arrays use Null.
For a teaching project, this is easier to read than a larger interface-based
model. You can inspect one struct and understand every RESP value the package
supports.
Helper constructors keep call sites readable:
resp.SimpleString("OK")
resp.BulkString("hello")
resp.NullBulkString()
resp.Array(resp.BulkString("PING"))
Parsing Starts With One Byte
The parser reads one byte, then dispatches based on that prefix:
prefix, err := r.ReadByte()
if err != nil {
return Value{}, err
}
switch Kind(prefix) {
case KindSimpleString:
// read a CRLF-terminated line
case KindError:
// read a CRLF-terminated line
case KindInteger:
// read a CRLF-terminated number
case KindBulkString:
// read length, payload, CRLF
case KindArray:
// read length, then nested values
default:
// reject unknown prefix
}
That shape is the heart of RESP. The protocol is small enough that the parser
does not need magic. It needs careful byte handling.
For simple strings, errors, and integers, reading a CRLF-terminated line is
enough:
+PONG\r\n
:1000\r\n
-ERR unknown command\r\n
Bulk strings are different.
Bulk Strings Must Be Read By Exact Length
A bulk string looks like this:
$5\r\nhello\r\n
The $5\r\n part is a length line. It says the payload is exactly 5 bytes.
After those 5 bytes, the parser must read one trailing CRLF.
That means a correct parser does not read a bulk string payload with "read until
newline". A payload can contain newlines:
$11\r\nhello\nworld\r\n
The newline between hello and world is data, not framing.
In the implementation, the parser allocates length + 2 bytes. It reads the
payload plus the trailing CRLF in one exact read:
buf := make([]byte, int(length)+2)
if _, err := io.ReadFull(r, buf); err != nil {
return Value{}, fmt.Errorf("read bulk string body: %w", err)
}
if buf[length] != '\r' || buf[length+1] != '\n' {
return Value{}, fmt.Errorf("bulk string missing trailing CRLF")
}
This is the most important habit when implementing RESP: line reading is for
metadata, exact byte reading is for bulk payloads.
Null Bulk Strings
Redis uses a null bulk string when a value is absent. For example, GET missing
does not return an empty string. It returns:
$-1\r\n
That is different from an empty string:
$0\r\n\r\n
The distinction matters:
-
$-1\r\nmeans "there is no value" -
$0\r\n\r\nmeans "there is a value, and it is empty"
The implementation represents this with `Value{Kind: KindBulkString, Null:
true}and exposes it throughresp.NullBulkString()`.
Arrays Make Commands Possible
Arrays are recursive RESP values:
*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n
The parser reads the array length, then calls ReadValue for each item. That
recursion is enough to support nested arrays too:
*2\r\n*1\r\n:1\r\n$3\r\nhey\r\n
Redis commands usually do not need deeply nested arrays, but supporting nested
values is a natural result of the protocol design.
Writing RESP Values
Encoding is the mirror image of parsing. Given a Value, the writer emits the
right prefix and payload:
resp.WriteValue(conn, resp.SimpleString("PONG"))
resp.WriteValue(conn, resp.BulkString("hello"))
resp.WriteValue(conn, resp.Error("ERR unknown command"))
For arrays, the writer emits the array length and then recursively writes each
item. That gives us one set of rules for both client-style command arrays and
server-style replies.
Turning Frames Into Commands
The TCP server is intentionally small:
-
Listen on
:6380 -
Accept connections
-
Start one goroutine per connection
-
Read RESP values in a loop
-
Treat each value as a Redis-style command array
-
Write a RESP reply
The server expects commands to be arrays of bulk strings. For example:
*1\r\n$4\r\nPING\r\n
Once decoded, the command handler receives something equivalent to:
resp.Array(resp.BulkString("PING"))
Then it applies simple command rules:
-
PINGreturns+PONG\r\n -
PING messagereturns the message as a bulk string -
ECHO messagereturns the message as a bulk string -
SET key valuestores the value and returns+OK\r\n -
GET keyreturns the value or$-1\r\n
The store is just:
type store struct {
mu sync.RWMutex
data map[string]string
}
That mutex is enough for this tiny server because multiple clients can connect
at the same time. One goroutine may be writing a key while another is reading
one.
Why The Server Returns RESP Errors
Malformed requests should not crash the server. They should become protocol
errors where possible:
-ERR expected RESP array command\r\n
-ERR wrong number of arguments for 'echo' command\r\n
-ERR unknown command 'NOPE'\r\n
This is another useful RESP lesson: errors are values too. An error reply is not
an exception on the wire. It is a frame with a - prefix.
Testing The Protocol
The tests cover both directions:
-
decoding RESP bytes into values
-
encoding values back into RESP bytes
-
rejecting malformed frames
-
handling server commands
Good protocol tests are concrete. They should include exact byte strings like:
"$5\r\nhello\r\n"
"$-1\r\n"
"*2\r\n$4\r\nECHO\r\n$5\r\nhello\r\n"
That style catches mistakes that higher-level tests often miss, especially
around CRLF handling and length-prefixed payloads.
Run everything with:
go test ./...
Trying It With redis-cli
Start the server:
go run ./cmd/resp-server
Then in another terminal:
redis-cli -p 6380 PING
redis-cli -p 6380 ECHO hello
redis-cli -p 6380 SET name redis
redis-cli -p 6380 GET name
Expected output:
PONG
hello
OK
redis
That is the satisfying moment: a real Redis client is speaking RESP to a small
Go server, and the server understands enough of the protocol to reply correctly.
What This Project Leaves Out
This implementation is intentionally minimal. A real Redis-compatible server
would need much more:
-
command pipelining behavior and buffering strategy
-
richer command set
-
expiration and eviction
-
persistence
-
authentication
-
pub/sub
-
transactions
-
replication
-
RESP3 support
-
careful memory limits and denial-of-service protections
Those are important, but they are separate from the first lesson. The first
lesson is that Redis' wire protocol is approachable. Once you understand prefix
bytes, CRLF, length-prefixed bulk strings, and arrays, you can build a useful
mental model of how Redis clients and servers communicate.
Final Thought
RESP is a good reminder that powerful systems do not always need complicated
protocols at their edges. Redis gets a lot of mileage from a format that is
human-readable in small examples, binary-safe when it matters, and simple enough
to implement in a few focused Go functions.
That simplicity is the real win. The tiny server is just proof that the idea
works.