Build Your Own Redis in 200 Lines of Python
Build a Redis clone in Python that the real redis-cli can talk to — a TCP server, the RESP protocol, key expiry, and an in-memory store in under 200 lines.
The fastest way to understand a tool is to rebuild a small version of it. Redis looks like magic — sub-millisecond reads, a clean protocol, dozens of commands — but the core is approachable. In this guide we’ll build a toy Redis server in Python that the real redis-cli can connect to and talk to. By the end you’ll have a working key-value store with GET, SET, DEL, INCR, and key expiry, in well under 200 lines. (If you want the conceptual tour first, see what Redis is.)
What Redis actually is, in one sentence
Redis is a server that holds a big dictionary in memory and speaks a simple text protocol over a TCP socket. That’s the whole trick: a hash map, plus a way for clients to talk to it over the network. We’ll build exactly those two halves.
The wire: RESP
Clients and servers speak RESP (REdis Serialization Protocol). It’s refreshingly readable. When you type SET name ada, the client sends an array of bulk strings:
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$3\r\nada\r\n
*3 means “three elements follow.” Each $N announces a string of N bytes, then the bytes, each terminated by \r\n. Replies are just as simple:
+OK\r\n— a simple string-ERR message\r\n— an error:42\r\n— an integer$3\r\nada\r\n— a bulk string$-1\r\n— null (the key didn’t exist)
Parse that, and any Redis client in the world will talk to you.
The store
Our database is a Python dict mapping keys to (value, expires_at). Expiry is lazy — we check the timestamp when a key is read and drop it if it’s stale:
import asyncio
import time
store = {} # key -> (value: bytes, expires_at: float | None)
def now():
return time.monotonic()
def lookup(key):
item = store.get(key)
if item is None:
return None
value, expires_at = item
if expires_at is not None and expires_at <= now():
del store[key] # lazily evict the expired key
return None
return item
Encoding replies
We use two tiny bytes subclasses as markers so one encode function knows whether to emit a simple string, an error, an integer, or a bulk string:
class Simple(bytes): pass # +OK
class Err(bytes): pass # -ERR ...
def encode(value):
if value is None:
return b"$-1\r\n" # null bulk string
if isinstance(value, Err):
return b"-" + value + b"\r\n"
if isinstance(value, Simple):
return b"+" + value + b"\r\n"
if isinstance(value, int):
return b":%d\r\n" % value
return b"$%d\r\n%b\r\n" % (len(value), value) # bulk string
The commands
Each command is a function that takes the parsed argument list and returns a Python value encode understands:
def cmd_set(args):
key, value = args[1], args[2]
expires_at = None
if len(args) >= 5 and args[3].upper() == b"PX":
expires_at = now() + int(args[4]) / 1000 # milliseconds
elif len(args) >= 5 and args[3].upper() == b"EX":
expires_at = now() + int(args[4]) # seconds
store[key] = (value, expires_at)
return Simple(b"OK")
def cmd_get(args):
item = lookup(args[1])
return item[0] if item else None
def cmd_del(args):
removed = 0
for key in args[1:]:
if lookup(key) is not None:
del store[key]
removed += 1
return removed
def cmd_incr(args):
item = lookup(args[1])
value = int(item[0]) + 1 if item else 1
store[args[1]] = (str(value).encode(), item[1] if item else None)
return value
COMMANDS = {
b"PING": lambda args: Simple(b"PONG"),
b"SET": cmd_set,
b"GET": cmd_get,
b"DEL": cmd_del,
b"INCR": cmd_incr,
}
That INCR — read, add one, store — is the same atomic-counter primitive real apps lean on for rate limiters and IDs.
Reading a command off the socket
This is the only fiddly part. We read the *N header, then for each argument read its $len line and exactly len + 2 bytes (the payload plus its trailing \r\n):
async def read_command(reader):
header = await reader.readline()
if not header:
return None
if not header.startswith(b"*"):
return header.split() # inline command, handy for nc
argc = int(header[1:])
args = []
for _ in range(argc):
length = int((await reader.readline())[1:])
chunk = await reader.readexactly(length + 2)
args.append(chunk[:-2]) # drop the trailing CRLF
return args
The server loop
asyncio.start_server hands us a reader/writer per client and handles concurrency for free. We loop: read a command, run it, write the reply.
async def handle(reader, writer):
while True:
try:
args = await read_command(reader)
except asyncio.IncompleteReadError:
break
if not args:
break
command = COMMANDS.get(args[0].upper())
reply = command(args) if command else Err(b"ERR unknown command")
writer.write(encode(reply))
await writer.drain()
writer.close()
async def main():
server = await asyncio.start_server(handle, "127.0.0.1", 6379)
print("toy-redis listening on port 6379")
async with server:
await server.serve_forever()
asyncio.run(main())
Talk to it with the real client
Start the server, then point the genuine redis-cli at it:
$ redis-cli
127.0.0.1:6379> PING
PONG
127.0.0.1:6379> SET name ada PX 5000
OK
127.0.0.1:6379> GET name
"ada"
127.0.0.1:6379> INCR visits
(integer) 1
# wait 5 seconds...
127.0.0.1:6379> GET name
(nil)
The official client believes it’s talking to Redis, because at the protocol level it is. That’s the payoff: a TCP server, a dict, and a parser are enough to be Redis-shaped.
What real Redis adds
What we skipped is what makes Redis production-grade: persistence (RDB snapshots and the AOF log), replication and clustering, the rich data types (lists, sets, sorted sets, streams), pub/sub, transactions, eviction policies, and a single-threaded event loop tuned over a decade. But none of that changes the shape you just built — it’s all bolted onto this same socket-and-dictionary core. A toy like this is also the cleanest way to feel why a networked, in-memory store is so often the right caching layer next to a slower database or behind a load balancer.
The takeaway
Rebuilding a tool from scratch turns a black box into something you can reason about. You now know what RESP looks like on the wire, why expiry is lazy, and why “it’s just a hash map over a socket” is both a joke and the literal truth. Clone it, add EXPIRE or LPUSH, and keep pulling the thread — the real source is famously readable once you’ve built the toy.
Tagged
Keep reading
The Lycoris Team · · 4 min read Redis Persistence: RDB vs AOF, Explained
Redis is in-memory, so RDB snapshots and the AOF log are how it survives a restart — each trades durability against performance differently.
Chisato · · 5 min read Database Migrations Explained: How Schema Changes Work
A database migration is a version-controlled script that changes a schema incrementally. How migration tools track state and apply changes safely.
The Lycoris Team · · 4 min read What Is an ORM? Object-Relational Mapping Explained
An ORM lets you query a database using your programming language's objects instead of raw SQL. How they work, what they trade off, and when to skip one.