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:

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:

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:

  1. Listen on :6380

  2. Accept connections

  3. Start one goroutine per connection

  4. Read RESP values in a loop

  5. Treat each value as a Redis-style command array

  6. 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:

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:

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:

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.