Build a Redis Clone in Go

Build a working Redis-compatible server from scratch using Go's standard library.

Introduction

30 Second Summary

Every time you check your social media feed, add an item to your shopping cart, or see a live sports score update, there is a lightning-fast database sitting between you and the app. It responds in microseconds because it keeps everything in memory instead of reading from disk.

In this project, you will build your own version of that database from scratch. You will implement a Redis-compatible server in pure Go that speaks the real Redis wire protocol (RESP2) and supports PING, ECHO, GET, SET, DEL, INCR, DECR, and EXISTS.

What You'll Build

A Redis-compatible TCP server written in pure Go (no external dependencies) that parses the RESP2 wire protocol, dispatches commands, and stores data in a thread-safe in-memory map with TTL-based expiry.

By the end of this project, you'll have:

  • A RESP2 protocol parser that reads and writes Redis wire format from raw TCP bytes.
  • A command dispatcher that routes PING, ECHO, GET, SET, DEL, INCR, DECR, and EXISTS to handler functions.
  • A thread-safe in-memory key-value store with TTL-based key expiry and lazy deletion.
  • Secret Mission: Replace goroutines with a single-threaded event loop, mirroring how real Redis handles concurrency.

Are there any prerequisites?

This project assumes basic Go experience. You should be comfortable with functions, structs, goroutines, and maps. You do not need any prior networking or protocol knowledge.

Set Up the Project

Before you can build a Redis-compatible server, you need a Go project structure, the official redis-cli client for testing, and an end-to-end test suite that tracks your progress.

In this step you will create the project directory, initialize the Go module, verify redis-cli is accessible, and drop in a pre-built e2e test file you can run after each stage.

In this step, get ready to:
  • Create the project directory and initialize a Go module.
  • Verify that redis-cli is installed and free up port 6379.
  • Add the e2e test file that validates your server at every stage.
Create the project directory and initialize Go module
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir go-redis
cd go-redis
  • Initialize the Go module by running:
go mod init go-redis

You should see go: creating new go.mod: module go-redis printed to the terminal.

Command not found?

If go is not found, you need to install Go first. Visit go.dev/dl and follow the instructions for your platform.

help me install Go on my machine

Verify redis-cli is accessible

You will use the real redis-cli client to test your server throughout this project. It speaks the same RESP2 wire protocol that production Redis uses.

  • Confirm redis-cli is installed by running:
redis-cli --version

You should see output like redis-cli 6.2.6 (your version number may differ).

Command not found?

If redis-cli is not found, install it with brew install redis on macOS. After the install finishes, run redis-cli --version again to confirm.

help me install redis-cli

Your server will listen on port 6379, which is the default Redis port. If you already have a local Redis server running, it is occupying that port.

  • Stop any existing Redis server by running:
redis-cli shutdown

No existing Redis server?

If you see Could not connect to Redis after running redis-cli shutdown, that means nothing is running on port 6379. You are good to continue.

Add the e2e test file

This test file starts your server on a random port, then runs redis-cli against it to verify each command. As you implement more commands, more tests will pass.

  • Create e2e_test.go in your project root and paste the following code:
//go:build e2e

package main

import (
	"fmt"
	"net"
	"os"
	"os/exec"
	"strings"
	"testing"
	"time"
)

var testAddr string

func TestMain(m *testing.M) {
	port, err := freePort()
	if err != nil {
		fmt.Fprintf(os.Stderr, "free port: %v\n", err)
		os.Exit(1)
	}
	testAddr = "127.0.0.1:" + port

	stop, err := startServer(testAddr)
	if err != nil {
		fmt.Fprintf(os.Stderr, "start server: %v\n", err)
		os.Exit(1)
	}
	defer stop()

	if err := waitForServer(testAddr); err != nil {
		fmt.Fprintf(os.Stderr, "wait for server: %v\n", err)
		os.Exit(1)
	}

	os.Exit(m.Run())
}

func TestE2E(t *testing.T) {
	requireRedisCLI(t)

	t.Run("ping", func(t *testing.T) {
		if got := redisCLI(t, "PING"); got != "PONG" {
			t.Fatalf("PING: got %q, want PONG", got)
		}
	})

	t.Run("echo", func(t *testing.T) {
		if got := redisCLI(t, "ECHO", "hello"); got != "hello" {
			t.Fatalf("ECHO: got %q, want hello", got)
		}
	})

	t.Run("set and get", func(t *testing.T) {
		key := "e2e-set-get"
		if got := redisCLI(t, "SET", key, "world"); got != "OK" {
			t.Fatalf("SET: got %q, want OK", got)
		}
		if got := redisCLI(t, "GET", key); got != "world" {
			t.Fatalf("GET: got %q, want world", got)
		}
	})

	t.Run("set with expiry", func(t *testing.T) {
		key := "e2e-expire"
		if got := redisCLI(t, "SET", key, "temp", "PX", "200"); got != "OK" {
			t.Fatalf("SET PX: got %q, want OK", got)
		}
		if got := redisCLI(t, "GET", key); got != "temp" {
			t.Fatalf("GET before expiry: got %q, want temp", got)
		}
		time.Sleep(300 * time.Millisecond)
		if got := redisCLI(t, "GET", key); !isNil(got) {
			t.Fatalf("GET after expiry: got %q, want nil", got)
		}
	})

	t.Run("delete", func(t *testing.T) {
		key := "e2e-del"
		redisCLI(t, "SET", key, "gone")
		if got := redisCLI(t, "DEL", key); got != "1" {
			t.Fatalf("DEL existing key: got %q, want 1", got)
		}
		if got := redisCLI(t, "GET", key); !isNil(got) {
			t.Fatalf("GET after DEL: got %q, want nil", got)
		}
		if got := redisCLI(t, "DEL", key); got != "0" {
			t.Fatalf("DEL missing key: got %q, want 0", got)
		}
	})

	t.Run("incr and decr", func(t *testing.T) {
		key := "e2e-incr-decr"
		if got := redisCLI(t, "INCR", key); got != "1" {
			t.Fatalf("INCR: got %q, want 1", got)
		}
		if got := redisCLI(t, "DECR", key); got != "0" {
			t.Fatalf("DECR: got %q, want 0", got)
		}
	})

	t.Run("exists", func(t *testing.T) {
		key := "e2e-exists"
		if got := redisCLI(t, "EXISTS", key); got != "0" {
			t.Fatalf("EXISTS: got %q, want 0", got)
		}
		redisCLI(t, "SET", key, "value")
		if got := redisCLI(t, "EXISTS", key); got != "1" {
			t.Fatalf("EXISTS: got %q, want 1", got)
		}
	})
}

func requireRedisCLI(t *testing.T) {
	t.Helper()
	if _, err := exec.LookPath("redis-cli"); err != nil {
		t.Skip("redis-cli not found in PATH")
	}
}

func redisCLI(t *testing.T, args ...string) string {
	t.Helper()
	requireRedisCLI(t)

	host, port, err := net.SplitHostPort(testAddr)
	if err != nil {
		t.Fatalf("split test addr: %v", err)
	}

	cliArgs := append([]string{"-h", host, "-p", port}, args...)
	out, err := exec.Command("redis-cli", cliArgs...).CombinedOutput()
	if err != nil {
		t.Fatalf("redis-cli %v: %v\n%s", args, err, out)
	}
	return strings.TrimSpace(string(out))
}

func isNil(s string) bool {
	return s == "" || s == "(nil)"
}

func freePort() (string, error) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		return "", err
	}
	port := ln.Addr().(*net.TCPAddr).Port
	ln.Close()
	return fmt.Sprintf("%d", port), nil
}

What does this test file do?

The //go:build e2e tag means these tests only run when you explicitly pass -tags e2e. They won't interfere with your normal development.

TestMain starts your server on a random free port, waits for it to accept connections, runs all tests, then shuts down. Each sub-test uses redis-cli to send real commands and checks the response.

💡 Tests will fail until you implement each command

The test file references a startServer and waitForServer function you haven't written yet. The file won't compile until you build the server in the next step. That's expected.

  • Confirm the files exist by running:
ls

You should see go.mod and e2e_test.go in the output.

Your project skeleton is ready. Next up, you will write the TCP listener that accepts connections and responds with PONG.

Implement the RESP2 Protocol Parser

Build a TCP Listener That Responds With PONG

Your project skeleton is in place. Now it's time to write the simplest possible server: open a TCP socket, accept connections in a loop, and write +PONG\r\n for every byte that arrives.

This gives you a working foundation you can test with redis-cli immediately, even before you build the protocol parser.

In this step, get ready to:
  • Create main.go with the server entry point.
  • Create server.go with the TCP listener and connection handler.
  • Test PING with redis-cli.
Create the server entry point
  • Create main.go in your go-redis folder and paste the following code:
package main

import "log"

// Entry point — starts the server on port 6379
func main() {
	if err := Run(":6379"); err != nil {
		log.Fatal(err)
	}
}

What does this code do?

main.go is intentionally minimal. It calls Run with the address :6379 (the default Redis port) and logs a fatal error if anything goes wrong. All the server logic lives in a separate file.

Create the TCP listener and connection handler
  • Create server.go in the same folder and paste the following code:
package main

import (
	"fmt"
	"net"
)

// Run starts the TCP server on the given address
func Run(addr string) error {
	listener, err := net.Listen("tcp", addr)
	if err != nil {
		return fmt.Errorf("listen on %s: %w", addr, err)
	}
	defer listener.Close()

	fmt.Printf("mini-redis listening on %s\n", addr)

	for {
		conn, err := listener.Accept()
		if err != nil {
			return fmt.Errorf("accept: %w", err)
		}
		go handleConnection(conn)
	}
}

// handleConnection reads from a client and always responds with +PONG
func handleConnection(conn net.Conn) {
	defer conn.Close()
	buf := make([]byte, 1024)
	for {
		_, err := conn.Read(buf)
		if err != nil {
			return
		}
		conn.Write([]byte("+PONG\r\n"))
	}
}

What does this code do?

net.Listen("tcp", addr) binds to port 6379. The infinite for loop calls listener.Accept() to wait for each new client.

Each connection is handed to handleConnection in its own goroutine, so multiple clients can connect simultaneously. Right now the handler ignores what the client sends and always writes +PONG\r\n back.

Test with redis-cli
  • Start your server by running:
go run .

You should see mini-redis listening on :6379 printed to the terminal.

  • Open a second terminal window and test the connection by running:
redis-cli PING

You should see PONG returned. Your server accepted the connection and responded.

Connection refused?

Make sure you ran redis-cli shutdown in the previous step. If another process is using port 6379, your server can't bind to it.

help me debug why my server can't bind to port 6379

✔️ Awesome, I've got everything!

Great. Double check you have saved both main.go and server.go.

ⓧ I'd like to double check the full code

Here is the full main.go:

package main

import "log"

func main() {
	if err := Run(":6379"); err != nil {
		log.Fatal(err)
	}
}

Here is the full server.go:

package main

import (
	"fmt"
	"net"
)

func Run(addr string) error {
	listener, err := net.Listen("tcp", addr)
	if err != nil {
		return fmt.Errorf("listen on %s: %w", addr, err)
	}
	defer listener.Close()

	fmt.Printf("mini-redis listening on %s\n", addr)

	for {
		conn, err := listener.Accept()
		if err != nil {
			return fmt.Errorf("accept: %w", err)
		}
		go handleConnection(conn)
	}
}

func handleConnection(conn net.Conn) {
	defer conn.Close()
	buf := make([]byte, 1024)
	for {
		_, err := conn.Read(buf)
		if err != nil {
			return
		}
		conn.Write([]byte("+PONG\r\n"))
	}
}

Your server accepts connections and always responds with PONG. Next up, you will define a command interface so each command can serialize itself and handle its own logic.

Build the Command Dispatcher and Basic Commands

Your server can now parse RESP2 messages from redis-cli, but it stays silent. Clients send commands and get nothing back.

In this step, you will build the command dispatcher that receives parsed commands, routes them to the correct handler function, and writes a properly formatted response back over the wire. By the end, your server will respond to real redis-cli commands.

In this step, get ready to:
  • Create a command dispatcher that routes parsed RESP2 arrays to handler functions.
  • Implement ECHO, SET and GET commands with proper RESP2 responses.
  • Test your server with the real redis-cli client.
Create the command dispatcher

A command dispatcher reads the parsed command name from the first element of the RESP2 array, then routes it to the appropriate handler function. Unknown commands get an error response.

Before writing the dispatcher, you need a placeholder type for the data store. The handlers you build later will need it, but for now it can be empty.

  • Add the Store module to store.go by pasting this code:
package main

// Placeholder for the key-value store (built in the next step)
type Store struct{}
  • Save store.go.

Now for the main event. The handleConnection function wraps the TCP connection in a buffered reader, reads RESP2 values in a loop, extracts the command name, and delegates to dispatch.

  • Open handler.go and paste the connection handler and dispatcher:
package main

import (
	"bufio"
	"fmt"
	"net"
	"strings"
)

func handleConnection(conn net.Conn, store *Store) {
	defer func() {
		fmt.Printf("Client disconnected: %s\n", conn.RemoteAddr().String())
		conn.Close()
	}()

	reader := bufio.NewReader(conn)

	for {
		value, err := ReadValue(reader)
		if err != nil {
			return
		}

		if value.Type != TypeArray || len(value.Array) == 0 {
			conn.Write(Value{Type: TypeError, Str: "ERR invalid command"}.Marshal())
			continue
		}

		command := strings.ToUpper(value.Array[0].Str)
		args := value.Array[1:]

		response := dispatch(command, args, store)
		conn.Write(response.Marshal())
	}
}

func dispatch(command string, args []Value, store *Store) Value {
	switch command {
	case "PING":
		return handlePing(args)
	case "ECHO":
		return handleEcho(args)
	default:
		return Value{Type: TypeError, Str: fmt.Sprintf("ERR unknown command '%s'", command)}
	}
}

What does this code do?

handleConnection defers closing the connection, then enters an infinite loop reading one RESP2 value at a time. If the read fails (client disconnected), it returns and the deferred cleanup runs.

The guard clause checks that the incoming value is a non-empty array. redis-cli always sends commands as arrays of bulk strings, so anything else is invalid.

dispatch uses a switch on the uppercased command name to route to the correct handler. Unrecognized commands get a RESP2 error response.

You won't see a change yet because dispatch calls handlePing and handleEcho which do not exist yet. The next chunk adds them.

Implement PING and ECHO

PING is the simplest Redis command. With no arguments it returns PONG as a simple string. With an argument it echoes that argument back as a bulk string. ECHO always returns its argument as a bulk string.

  • Add the PING and ECHO handlers below the dispatch function in handler.go:
func handlePing(args []Value) Value {
	if len(args) == 0 {
		return Value{Type: TypeSimpleString, Str: "PONG"}
	}
	return Value{Type: TypeBulkString, Str: args[0].Str}
}

func handleEcho(args []Value) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'echo' command"}
	}
	return Value{Type: TypeBulkString, Str: args[0].Str}
}

What does this code do?

handlePing checks whether any arguments were passed. With zero arguments it returns the simple string +PONG\r\n. With an argument it returns that argument as a bulk string (matching real Redis behavior).

handleEcho requires exactly one argument and returns it as a bulk string. If no argument is provided it returns a RESP2 error.

One more change before this compiles. The old main.go calls handleConnection(conn) with one argument, but the new signature expects two.

  • In main.go, find this line inside the for loop:
go handleConnection(conn)
  • Replace it with this line to pass nil for the store:
go handleConnection(conn, nil)
  • Save handler.go and main.go.

Seeing a compile error?

If you see an error about Store being undefined, make sure you saved store.go with the placeholder type Store struct{} from the previous chunk.

If you see an error about too many arguments to handleConnection, confirm you updated the call in main.go to go handleConnection(conn, nil).

Help me fix a compile error in my mini-redis project

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: handler.go, main.go, and store.go.

ⓧ I'd like to double check the full code

Here is the full handler.go file at this point:

package main

import (
	"bufio"
	"fmt"
	"net"
	"strings"
)

func handleConnection(conn net.Conn, store *Store) {
	defer func() {
		fmt.Printf("Client disconnected: %s\n", conn.RemoteAddr().String())
		conn.Close()
	}()

	reader := bufio.NewReader(conn)

	for {
		value, err := ReadValue(reader)
		if err != nil {
			return
		}

		if value.Type != TypeArray || len(value.Array) == 0 {
			conn.Write(Value{Type: TypeError, Str: "ERR invalid command"}.Marshal())
			continue
		}

		command := strings.ToUpper(value.Array[0].Str)
		args := value.Array[1:]

		response := dispatch(command, args, store)
		conn.Write(response.Marshal())
	}
}

func dispatch(command string, args []Value, store *Store) Value {
	switch command {
	case "PING":
		return handlePing(args)
	case "ECHO":
		return handleEcho(args)
	default:
		return Value{Type: TypeError, Str: fmt.Sprintf("ERR unknown command '%s'", command)}
	}
}

func handlePing(args []Value) Value {
	if len(args) == 0 {
		return Value{Type: TypeSimpleString, Str: "PONG"}
	}
	return Value{Type: TypeBulkString, Str: args[0].Str}
}

func handleEcho(args []Value) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'echo' command"}
	}
	return Value{Type: TypeBulkString, Str: args[0].Str}
}

And main.go should look like this:

package main

import (
	"fmt"
	"log"
	"net"
)

func main() {
	listener, err := net.Listen("tcp", ":6379")
	if err != nil {
		log.Fatalf("Failed to bind to port 6379: %v", err)
	}
	defer listener.Close()

	fmt.Println("mini-redis server listening on :6379")

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Printf("Accept error: %v", err)
			return
		}
		go handleConnection(conn, nil)
	}
}
Verify with redis-cli

Time to see your server talk back. You will start the server and then send commands from redis-cli in a second terminal window.

  • Start your server by running:
go run .
  • You should see mini-redis server listening on :6379 in the terminal.
  • Open a second terminal window.
  • Test PING by running:
redis-cli ping
  • You should see PONG as the response.
  • Test ECHO by running:
redis-cli echo "hello world"
  • You should see "hello world" as the response.
  • Test an unknown command by running:
redis-cli UNKNOWN
  • You should see an error message containing ERR unknown command 'UNKNOWN'.

What should I see?

In the server terminal, you should also see Client disconnected messages after each redis-cli command. This is because redis-cli connects, sends one command, reads the response, and disconnects.

🙋‍♀️ Not getting a response?

If redis-cli hangs without a response, make sure you saved all files and restarted the server with go run . after your edits.

If you see a connection refused error, confirm your server is still running in the first terminal. Check that no other process is using port 6379.

Help me debug why redis-cli is not getting a response from my Go server

  • Stop the server with Ctrl+C when you are done testing.

Your server now speaks Redis. Next up, you will build the in-memory store so it can remember data with SET, GET, and DEL.

Build the In-Memory Store with SET, GET, and DEL

Your server can respond to PING and ECHO, but it has no memory. Every real database needs a place to store data, and Redis keeps everything in RAM for speed.

In this step, you will build a thread-safe in-memory store and wire up the SET, GET, and DEL commands. By the end, your server will behave like a real key-value database that the official redis-cli can talk to.

In this step, get ready to:
  • Build a store with SET, GET, and DEL methods that supports TTL-based key expiry.
  • Add command handlers in handler.go that parse arguments and call the store.
  • Test the full flow with redis-cli to confirm SET, GET, DEL, and expiry all work.
Build the store in store.go

The store is the heart of your Redis server. It holds all key-value pairs in a Go map, protected by a mutex so multiple goroutines can access it safely.

  • Open store.go and add the Entry struct with its expiry check method:
package main

import (
	"sync"
	"time"
)

// Entry holds a single stored value and its optional expiry time
type Entry struct {
	value  string
	expiry time.Time
}

// isExpired returns true if this entry has a set expiry that has passed
func (e Entry) isExpired() bool {
	if e.expiry.IsZero() {
		return false
	}
	return time.Now().After(e.expiry)
}

What does this code do?

The Entry struct pairs each stored value with an optional expiry timestamp.

The isExpired() method checks whether the current time has passed the entry's expiry. If expiry is zero (the default), the key never expires.

  • Below the isExpired method, add the Store struct and its constructor:
// Store holds all key-value data, protected by a read-write mutex
type Store struct {
	mu   sync.RWMutex
	data map[string]Entry
}

// NewStore creates an empty store ready to accept keys
func NewStore() *Store {
	return &Store{
		data: make(map[string]Entry),
	}
}

What is a sync.RWMutex?

A sync.RWMutex is a reader-writer lock. Multiple goroutines can hold a read lock at the same time (for GET), but only one goroutine can hold the write lock (for SET or DEL).

This means reads never block each other, and writes get exclusive access. It is the same concurrency pattern that real Redis uses under the hood for its data structures.

  • Add the Set method below NewStore:
// Set stores a key-value pair with an optional expiry duration
func (s *Store) Set(key, value string, expiry time.Duration) {
	s.mu.Lock()
	defer s.mu.Unlock()

	entry := Entry{value: value}
	if expiry > 0 {
		entry.expiry = time.Now().Add(expiry)
	}
	s.data[key] = entry
}

What does this code do?

The Set method acquires a full write lock with s.mu.Lock(), then stores the entry in the map.

If expiry is greater than zero, the method calculates the absolute deadline by adding the duration to the current time. Otherwise the entry lives forever.

  • Add the Get method below Set:
// Get retrieves a value by key, returning false if not found or expired
func (s *Store) Get(key string) (string, bool) {
	s.mu.RLock()
	entry, exists := s.data[key]
	s.mu.RUnlock()

	if !exists {
		return "", false
	}

	if entry.isExpired() {
		s.mu.Lock()
		delete(s.data, key)
		s.mu.Unlock()
		return "", false
	}

	return entry.value, true
}

What is lazy deletion?

Instead of running a background timer to clean up expired keys, Get checks expiry at read time. If the key has expired, it deletes the entry right then and returns not-found.

This is called lazy deletion. Real Redis uses this exact strategy as its primary expiry mechanism. Expired keys only get cleaned up when someone tries to read them.

  • Add the Del method below Get:
// Del removes a key from the store, returning true if it existed
func (s *Store) Del(key string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()

	_, exists := s.data[key]
	if !exists {
		return false
	}
	delete(s.data, key)
	return true
}

What does this code do?

The Del method acquires a write lock, checks if the key exists, and removes it from the map. It returns true if a key was actually deleted, which lets the command handler report back how many keys were removed.

  • Save store.go.
  • Confirm the file compiles by running:
go build .

You should see no errors.

Seeing a compile error?

Check that your import block at the top of store.go includes both "sync" and "time". Also confirm that every opening brace { has a matching closing brace }.

help me debug a compile error in store.go

✔️ Awesome, I've got everything!

Great. Double check you have saved store.go before moving on.

ⓧ I'd like to double check the full code

package main

import (
	"sync"
	"time"
)

type Entry struct {
	value  string
	expiry time.Time
}

func (e Entry) isExpired() bool {
	if e.expiry.IsZero() {
		return false
	}
	return time.Now().After(e.expiry)
}

type Store struct {
	mu   sync.RWMutex
	data map[string]Entry
}

func NewStore() *Store {
	return &Store{
		data: make(map[string]Entry),
	}
}

func (s *Store) Set(key, value string, expiry time.Duration) {
	s.mu.Lock()
	defer s.mu.Unlock()

	entry := Entry{value: value}
	if expiry > 0 {
		entry.expiry = time.Now().Add(expiry)
	}
	s.data[key] = entry
}

func (s *Store) Get(key string) (string, bool) {
	s.mu.RLock()
	entry, exists := s.data[key]
	s.mu.RUnlock()

	if !exists {
		return "", false
	}

	if entry.isExpired() {
		s.mu.Lock()
		delete(s.data, key)
		s.mu.Unlock()
		return "", false
	}

	return entry.value, true
}

func (s *Store) Del(key string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()

	_, exists := s.data[key]
	if !exists {
		return false
	}
	delete(s.data, key)
	return true
}
Add the SET, GET, and DEL command handlers

The store holds data, but your server does not know how to route SET, GET, or DEL commands to it yet. You need handler functions that parse the RESP2 arguments and call the appropriate store method.

  • Open handler.go and update the import block to include "strconv" and "time":
import (
	"bufio"
	"fmt"
	"net"
	"strconv"
	"strings"
	"time"
)
  • Below the handleEcho function, add the handleSet function:
// handleSet stores a key-value pair, with optional EX (seconds) or PX (milliseconds) expiry
func handleSet(args []Value, store *Store) Value {
	if len(args) < 2 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'set' command"}
	}

	key := args[0].Str
	value := args[1].Str
	var expiry time.Duration

	// Parse optional EX/PX arguments
	for i := 2; i < len(args)-1; i++ {
		option := strings.ToUpper(args[i].Str)
		switch option {
		case "EX":
			seconds, err := strconv.Atoi(args[i+1].Str)
			if err != nil {
				return Value{Type: TypeError, Str: "ERR value is not an integer or out of range"}
			}
			expiry = time.Duration(seconds) * time.Second
			i++
		case "PX":
			ms, err := strconv.Atoi(args[i+1].Str)
			if err != nil {
				return Value{Type: TypeError, Str: "ERR value is not an integer or out of range"}
			}
			expiry = time.Duration(ms) * time.Millisecond
			i++
		}
	}

	store.Set(key, value, expiry)
	return Value{Type: TypeSimpleString, Str: "OK"}
}

What does this code do?

The handleSet function extracts the key and value from the parsed RESP array. It then loops through any remaining arguments looking for EX (seconds) or PX (milliseconds) to set a TTL.

If the learner types SET key value EX 10, the handler converts 10 seconds into a time.Duration and passes it to the store.

  • Below handleSet, add the handleGet function:
// handleGet retrieves a key's value, returning null bulk if not found
func handleGet(args []Value, store *Store) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'get' command"}
	}

	value, found := store.Get(args[0].Str)
	if !found {
		return Value{Type: TypeBulkString, IsNull: true}
	}
	return Value{Type: TypeBulkString, Str: value}
}

What does this code do?

When a key is not found (or has expired via lazy deletion), handleGet returns a null bulk string. In RESP2 wire format this is $-1\r\n, which redis-cli displays as (nil).

  • Below handleGet, add the handleDel function:
// handleDel removes one or more keys, returning the count deleted
func handleDel(args []Value, store *Store) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'del' command"}
	}

	count := 0
	for _, arg := range args {
		if store.Del(arg.Str) {
			count++
		}
	}
	return Value{Type: TypeInteger, Integer: int64(count)}
}

What does this code do?

Redis DEL accepts multiple keys in a single call. The handler loops through all key arguments, calls store.Del on each, and tallies up how many were actually deleted. It returns the count as a RESP2 integer.

  • In handler.go, find the dispatch function and locate the default: case.
  • Find this code:
	case "ECHO":
		return handleEcho(args)
	default:
  • Add the three new cases between the ECHO case and default, so the block looks like this:
	case "ECHO":
		return handleEcho(args)
	case "SET":
		return handleSet(args, store)
	case "GET":
		return handleGet(args, store)
	case "DEL":
		return handleDel(args, store)
	default:
  • Save handler.go.

Now update main.go to create a real store instead of passing nil.

  • In main.go, find this line inside the main function:
	for {
  • Add the following line directly above for {:
	store := NewStore()
  • Next, find the line that passes nil to handleConnection:
		go handleConnection(conn, nil)
  • Replace nil with store, so the line now looks like this:
		go handleConnection(conn, store)
  • Save main.go.
  • Confirm everything compiles by running:
go build .

You should see no errors.

Seeing an undefined error?

If you see an error about strconv or time being undefined, make sure you added both packages to the import block at the top of handler.go. Go requires every import to be listed explicitly.

help me fix an undefined import error in handler.go

✔️ Awesome, I've got everything!

Great. Make sure both handler.go and main.go are saved before continuing.

ⓧ I'd like to double check the full code

Here is the full handler.go:

package main

import (
	"bufio"
	"fmt"
	"net"
	"strconv"
	"strings"
	"time"
)

func handleConnection(conn net.Conn, store *Store) {
	defer func() {
		fmt.Printf("Client disconnected: %s\n", conn.RemoteAddr().String())
		conn.Close()
	}()

	reader := bufio.NewReader(conn)

	for {
		value, err := ReadValue(reader)
		if err != nil {
			return
		}

		if value.Type != TypeArray || len(value.Array) == 0 {
			conn.Write(Value{Type: TypeError, Str: "ERR invalid command"}.Marshal())
			continue
		}

		command := strings.ToUpper(value.Array[0].Str)
		args := value.Array[1:]

		response := dispatch(command, args, store)
		conn.Write(response.Marshal())
	}
}

func dispatch(command string, args []Value, store *Store) Value {
	switch command {
	case "PING":
		return handlePing(args)
	case "ECHO":
		return handleEcho(args)
	case "SET":
		return handleSet(args, store)
	case "GET":
		return handleGet(args, store)
	case "DEL":
		return handleDel(args, store)
	default:
		return Value{Type: TypeError, Str: fmt.Sprintf("ERR unknown command '%s'", command)}
	}
}

func handlePing(args []Value) Value {
	if len(args) == 0 {
		return Value{Type: TypeSimpleString, Str: "PONG"}
	}
	return Value{Type: TypeBulkString, Str: args[0].Str}
}

func handleEcho(args []Value) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'echo' command"}
	}
	return Value{Type: TypeBulkString, Str: args[0].Str}
}

func handleSet(args []Value, store *Store) Value {
	if len(args) < 2 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'set' command"}
	}

	key := args[0].Str
	value := args[1].Str
	var expiry time.Duration

	// Parse optional EX/PX arguments
	for i := 2; i < len(args)-1; i++ {
		option := strings.ToUpper(args[i].Str)
		switch option {
		case "EX":
			seconds, err := strconv.Atoi(args[i+1].Str)
			if err != nil {
				return Value{Type: TypeError, Str: "ERR value is not an integer or out of range"}
			}
			expiry = time.Duration(seconds) * time.Second
			i++
		case "PX":
			ms, err := strconv.Atoi(args[i+1].Str)
			if err != nil {
				return Value{Type: TypeError, Str: "ERR value is not an integer or out of range"}
			}
			expiry = time.Duration(ms) * time.Millisecond
			i++
		}
	}

	store.Set(key, value, expiry)
	return Value{Type: TypeSimpleString, Str: "OK"}
}

func handleGet(args []Value, store *Store) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'get' command"}
	}

	value, found := store.Get(args[0].Str)
	if !found {
		return Value{Type: TypeBulkString, IsNull: true}
	}
	return Value{Type: TypeBulkString, Str: value}
}

func handleDel(args []Value, store *Store) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'del' command"}
	}

	count := 0
	for _, arg := range args {
		if store.Del(arg.Str) {
			count++
		}
	}
	return Value{Type: TypeInteger, Integer: int64(count)}
}

And here is the full main.go:

package main

import (
	"fmt"
	"log"
	"net"
)

func main() {
	listener, err := net.Listen("tcp", ":6379")
	if err != nil {
		log.Fatalf("Failed to bind to port 6379: %v", err)
	}
	defer listener.Close()

	fmt.Println("mini-redis server listening on :6379")

	store := NewStore()

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Printf("Accept error: %v", err)
			return
		}
		go handleConnection(conn, store)
	}
}
Test your store with redis-cli

Time to prove your server works as a real key-value store. You will use the official redis-cli to issue SET, GET, and DEL commands against your server.

  • Start your server by running:
go run .

You should see mini-redis server listening on :6379 in the terminal.

  • Open a second terminal window.
  • Store a value by running:
redis-cli set name "Redis Clone"

You should see OK returned.

  • Retrieve the value by running:
redis-cli get name

You should see "Redis Clone" returned.

  • Test key expiry by running:
redis-cli set temp "gone" EX 2

This sets the key temp with a 2-second TTL.

  • Wait 3 seconds, then run:
redis-cli get temp

You should see (nil) because the key expired and was lazily deleted.

  • Test deletion by running:
redis-cli del name

You should see (integer) 1 indicating one key was removed.

  • Confirm the key is gone by running:
redis-cli get name

You should see (nil).

Getting connection refused?

Make sure your server is still running in the first terminal (you should see the mini-redis server listening on :6379 message). If you stopped it to rebuild, run go run . again before using redis-cli.

help me debug a connection refused error when running redis-cli against my mini-redis server

Your server now has persistent memory with TTL support. Next up, you will add INCR, DECR, and EXISTS commands to manipulate stored values as integers.

Add INCR, DECR, and EXISTS

Your server stores and retrieves string data. But real Redis also treats stored strings as integers when you ask it to. INCR and DECR are the building blocks for counters, rate limiters, and distributed locks.

In this step you will add three new commands: INCR, DECR, and EXISTS. The trick is that INCR and DECR initialize missing keys to 1 / -1 rather than returning an error.

In this step, get ready to:
  • Implement INCR, DECR, and EXISTS handlers.
  • Wire up the new dispatch cases.
  • Test all three commands with redis-cli.
Add the INCR and DECR handlers

Both commands treat stored strings as integers. If the key doesn't exist, INCR starts from 0 and adds 1 (resulting in 1). DECR starts from 0 and subtracts 1 (resulting in -1).

  • In handler.go, add the INCR and DECR handlers below handleDel:
// handleIncr increments a key's integer value by 1, initializing to 1 if missing
func handleIncr(args []Value, store *Store) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'incr' command"}
	}
	result, err := store.IncrBy(args[0].Str, 1)
	if err != nil {
		return Value{Type: TypeError, Str: "ERR value is not an integer or out of range"}
	}
	return Value{Type: TypeInteger, Integer: result}
}

// handleDecr decrements a key's integer value by 1, initializing to -1 if missing
func handleDecr(args []Value, store *Store) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'decr' command"}
	}
	result, err := store.IncrBy(args[0].Str, -1)
	if err != nil {
		return Value{Type: TypeError, Str: "ERR value is not an integer or out of range"}
	}
	return Value{Type: TypeInteger, Integer: result}
}

What does this code do?

Both handlers delegate to a single store.IncrBy method (which you'll add next) with different deltas: +1 for INCR and -1 for DECR. If the stored value is not a valid integer string, the store returns an error.

Add the EXISTS handler and IncrBy store method
  • Add the EXISTS handler below the DECR handler in handler.go:
// handleExists returns 1 if the key exists, 0 otherwise
func handleExists(args []Value, store *Store) Value {
	if len(args) == 0 {
		return Value{Type: TypeError, Str: "ERR wrong number of arguments for 'exists' command"}
	}
	value, found := store.Get(args[0].Str)
	_ = value
	if !found {
		return Value{Type: TypeInteger, Integer: 0}
	}
	return Value{Type: TypeInteger, Integer: 1}
}
  • Now add the IncrBy method to store.go. First update the import block to include "fmt" and "strconv":
import (
	"fmt"
	"strconv"
	"sync"
	"time"
)
  • Add the IncrBy method below the Del method in store.go:
// IncrBy atomically increments a key's integer value by delta
func (s *Store) IncrBy(key string, delta int64) (int64, error) {
	s.mu.Lock()
	defer s.mu.Unlock()

	entry, exists := s.data[key]
	var current int64

	if exists {
		if entry.isExpired() {
			delete(s.data, key)
			current = 0
		} else {
			val, err := strconv.ParseInt(entry.value, 10, 64)
			if err != nil {
				return 0, fmt.Errorf("not an integer")
			}
			current = val
		}
	}

	current += delta
	s.data[key] = Entry{value: strconv.FormatInt(current, 10)}
	return current, nil
}

Why hold the lock for the entire operation?

IncrBy reads the current value, adds the delta, and writes the result. If you released the lock between read and write, another goroutine could read the same old value and both would write the same result. You'd lose one increment.

Holding the write lock for the full cycle makes INCR atomic. This is the same guarantee real Redis provides.

Wire up the dispatch cases
  • In handler.go, find the dispatch function. Find this line:
	case "DEL":
		return handleDel(args, store)
	default:
  • Add three new cases between "DEL" and default, so it looks like this:
	case "DEL":
		return handleDel(args, store)
	case "INCR":
		return handleIncr(args, store)
	case "DECR":
		return handleDecr(args, store)
	case "EXISTS":
		return handleExists(args, store)
	default:
  • Save both handler.go and store.go.
Test with redis-cli
  • Restart your server by pressing Ctrl+C and running:
go run .
  • In a second terminal, test INCR and DECR by running these commands one at a time:
redis-cli INCR counter
redis-cli INCR counter
redis-cli DECR counter
redis-cli EXISTS counter
redis-cli EXISTS missing

You should see (integer) 1, (integer) 2, (integer) 1, (integer) 1, and (integer) 0 respectively.

Seeing a compile error?

Make sure your store.go import block includes both "fmt" and "strconv". The IncrBy method uses both packages.

help me fix a compile error in my INCR/DECR implementation

Secret mission

Replace Goroutines With an Event Loop

Your store currently needs a mutex because goroutines access it concurrently. But what if only one thread ever touched the data? Refactor your server so that commands are processed in a single event loop, removing the need for any locks on your store.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about.

Resources you used:

  • The mini-redis project directory on your Desktop.

Keep everything running

No action needed. Choose this if you want to keep experimenting with your Redis server or add more commands.

  • Your mini-redis binary is ready to run anytime with go run . from inside the mini-redis directory.
  • The server only runs when you start it manually. It creates no persistent state, background processes, or stored data between sessions.

Pause - I'll come back to this later

There is nothing to pause. The server only runs while you actively have it running in your terminal.

  • If your server is still running, press Ctrl+C in the terminal to stop it.
  • Your project files stay on your Desktop until you decide to delete them.

Delete - I don't want to use this again

Remove the project directory to start fresh.

  • If your server is still running, press Ctrl+C in the terminal to stop it.
  • Delete the project folder by running this command:
cd ~/Desktop
rm -rf mini-redis
  • Confirm the folder is gone by running:
ls ~/Desktop | grep mini-redis

You should see no output, confirming the directory has been removed.

Nice Work!

Nice Work!

You just built a fully functional Redis-compatible server from raw TCP sockets and byte parsing. Your server speaks the same wire protocol as production Redis and responds to the real redis-cli client with 8 commands.

You've learned how to:

  • Built a TCP server from scratch using Go's net package that handles concurrent client connections with goroutines.
  • Implemented the RESP2 wire protocol parser, the same binary format real Redis uses for all client-server communication.
  • Created a thread-safe in-memory key-value store supporting 8 commands (PING, ECHO, GET, SET, DEL, INCR, DECR, EXISTS) with TTL-based expiry.
  • Secret Mission: Replaced the goroutine-per-client model with a single-threaded event loop, mirroring how real Redis handles concurrency.

Your implementation covers the core of what makes Redis tick: a wire protocol, a command dispatcher, and an in-memory store. The real Redis server adds persistence (RDB snapshots and AOF logs), replication, pub/sub, Lua scripting, and hundreds more commands on top of this same foundation.

If you want to go deeper, the full RESP protocol specification is available at redis.io/docs/latest/develop/reference/protocol-spec. It covers RESP3 (the newer version), inline commands, and edge cases your parser doesn't handle yet.

Ready to quiz yourself?