A Redis-compatible in-memory data store, written from scratch in Python — raw TCP sockets, a hand-written protocol parser, and asyncio. No redis-py, no third-party RESP library, no framework.
Point a real redis-cli at it and it works:
$ redis-cli -p 6380
127.0.0.1:6380> SET user:42 "Goutam" EX 60
OK
127.0.0.1:6380> TTL user:42
(integer) 60
127.0.0.1:6380> RPUSH langs python go rust
(integer) 3
127.0.0.1:6380> LRANGE langs 0 -1
1) "python"
2) "go"
3) "rust"Redis is the piece of infrastructure most backend engineers use daily and fewest have looked inside. This is a working re-implementation of its core: the wire protocol, the keyspace, TTL expiry, durability, replication, and pub/sub — each built from the primitive underneath it rather than pulled from a library.
The interesting part isn't that it stores keys. It's the set of problems you can't avoid once you commit to speaking a real protocol over a real socket:
| Problem | Where it's solved |
|---|---|
A command arrives split across three recv() calls |
Incremental parser |
Three commands arrive in a single recv() (pipelining) |
Incremental parser |
| Two clients mutate the same key concurrently | Single-threaded event loop |
| A key with a TTL is set and never read again | Active + passive expiry |
| The process is killed mid-write | AOF fsync policies |
| A crash leaves a half-written command on disk | Truncated-tail recovery |
| Replacing an open file on Windows vs. POSIX | BGREWRITEAOF |
| A replica needs current state, not a full write log | Snapshot handshake |
Each is written up below with the reasoning, including the tradeoffs that were taken deliberately and the ones that are genuine limitations.
- RESP2 wire protocol — hand-written incremental encoder/decoder, binary-safe, compatible with
redis-cliand any RESP2 client - Five data types — strings, lists, hashes, sets, plus TTL metadata, with real
WRONGTYPEenforcement - 35+ commands — including
INCR/DECR,LRANGEwith negative indices,HGETALL,SCARD,EXPIRE/TTL/PERSIST - TTL expiry — lazy (on access) and an active background sampling sweep, mirroring Redis's own two-pronged approach
- AOF persistence — three fsync policies, startup replay, corrupted-tail recovery, and
BGREWRITEAOFcompaction - Pub/Sub — channel registry, subscribe-mode command restrictions, multi-subscriber fan-out
- Leader–follower replication — snapshot handshake, live command streaming, read-only replicas
- Async benchmark harness — throughput and p50/p95/p99 latency, pointable at a real
redis-serverfor comparison - A rate limiter built on top of it — demonstrating the store solving an actual problem
- 147 tests covering the protocol, every command, expiry timing, crash recovery, pub/sub, and replication
git clone https://github.com/IITBGoutam/pycache.git
cd pycache
python -m pycache.serverPyCache listening on ('127.0.0.1', 6380) (appendfsync=everysec, standalone/leader)
No installation, no virtualenv, no dependencies — the server is standard
library only. To run the tests you'll want pytest:
pip install -r requirements.txt
pytest # 147 passedAny RESP2 client works. If you have redis-cli installed:
redis-cli -p 6380If you don't, the project's own encoder is a fine client:
import socket
from pycache.resp import RESPParser, encode, NEED_MORE
sock = socket.create_connection(("127.0.0.1", 6380))
parser = RESPParser()
def command(*args):
sock.sendall(encode(list(args)))
while (reply := parser.next_command()) is NEED_MORE:
parser.feed(sock.recv(65536))
return reply
print(command("SET", "greeting", "hello")) # OK
print(command("GET", "greeting")) # hellopython -m pycache.server --port 6380 --appendfsync everysec
python -m pycache.server --port 6381 --replicaof 127.0.0.1 6380 # run as a replica| Flag | Values | Default |
|---|---|---|
--host |
bind address | 127.0.0.1 |
--port |
bind port | 6380 |
--appendfsync |
always | everysec | no |
everysec |
--replicaof HOST PORT |
follow a leader | (standalone) |
flowchart TD
C1["Client"] --> S
C2["redis-cli"] --> S
S["server.py<br/>asyncio connection loop"] --> P["resp.py<br/>RESPParser: incremental decode"]
P --> D["commands.py<br/>name to handler dispatch"]
D --> ST["store.py<br/>DataStore: the keyspace"]
ST -. "TTL sweep" .-> EX["expiry.py<br/>background task"]
D -. "writes" .-> AOF["persistence/aof.py<br/>append + fsync"]
D -. "writes" .-> REP["replication.py<br/>stream to followers"]
D -. "PUBLISH" .-> PS["pubsub.py<br/>channel fan-out"]
AOF --> DISK[("appendonly.aof")]
REP --> F["Replica<br/>read-only"]
PS --> SUBS["Subscribed clients"]
A command's path through the system: bytes land in RESPParser, which
yields complete command arrays; dispatch() looks the name up in a
decorator-populated table and runs the handler against the shared
DataStore; the reply is encoded back to RESP. If it was a write, it is
appended to the AOF and streamed to replicas before the client is told
it succeeded — so an acknowledged write is never one the server could lose.
| Module | Lines | Responsibility |
|---|---|---|
pycache/resp.py |
256 | RESP2 encoder + resumable incremental decoder |
pycache/commands.py |
422 | Dispatch table and every command handler |
pycache/store.py |
390 | Keyspace, type enforcement, TTL bookkeeping |
pycache/server.py |
233 | Connection loop, write-path ordering, lifecycle |
pycache/persistence/aof.py |
229 | Append path, replay, compaction |
pycache/replication.py |
115 | Follower registry, handshake, stream application |
pycache/pubsub.py |
92 | Channel registry and fan-out |
pycache/expiry.py |
31 | Active-expiry background sweep |
TCP is a byte stream, not a message stream. It gives you no framing
guarantees at all: a single SET key value can arrive as three separate
recv() calls, and three pipelined commands can arrive as one. A parser
that assumes one read equals one command is subtly broken under exactly
the load you'd want a cache to handle.
RESPParser separates buffering from parsing. feed() appends bytes;
next_command() attempts to parse one complete value from the front of the
buffer and only consumes those bytes once the value is fully parsed —
otherwise it returns a NEED_MORE sentinel and leaves the buffer untouched.
That single invariant handles both hard cases at once:
parser.feed(b"*3\r\n$3\r\nSET") # partial command
parser.next_command() # NEED_MORE — nothing consumed
parser.feed(b"\r\n$3\r\nfoo\r\n$3\r\nbar\r\n*1\r\n$4\r\nPING\r\n")
parser.next_command() # ['SET', 'foo', 'bar']
parser.next_command() # ['PING'] ← pipelined, no re-feed
parser.next_command() # NEED_MOREBulk strings are decoded via latin-1, which maps bytes 0–255 onto code
points one-to-one. The round trip bytes → str → bytes is therefore
lossless for arbitrary binary payloads, which is how the store stays
binary-safe while the rest of the codebase works with plain str.
All connections share one DataStore with no locks anywhere. That's not
an oversight — it's the design, and it's the same one real Redis uses.
Command handlers are ordinary synchronous functions containing no await.
Because an asyncio coroutine can only yield control at an await point, a
handler that has none runs to completion before any other connection's
handler can start. Every command is therefore atomic with respect to every
other command, for free.
Thread-per-connection was rejected deliberately: it would require locking every keyspace access (or one global lock, which defeats the point) for no throughput gain on a workload that is network-I/O bound with microsecond-scale in-memory operations.
This isn't just an internal detail — it's a guarantee the rate limiter below is built on.
Passive expiry runs at the top of every keyed access: if the TTL has passed, the key is deleted and the access proceeds as though it never existed. This guarantees an expired key is never returned, without ever scanning the keyspace.
But passive expiry alone leaks. A key that is given a TTL and then never
touched again is never checked, so it occupies memory forever. So there's
also an active sweep (expiry.py) — a background task that every 100ms
samples up to 20 keys carrying a TTL and reaps whichever have expired.
Sampling rather than scanning keeps the tick cost constant regardless of
keyspace size.
DBSIZE exists specifically to make this observable: it's a raw
len(keyspace) with no expiry side effects of its own, so it can report on
the sweep without a read triggering passive expiry and masking the result.
Every write command is appended to appendonly.aof in the same RESP format
clients use — which means recovery is not special-purpose code. Startup
replay just feeds the file back through the ordinary parser and dispatcher:
replay_bytes(data, store, dispatch) # the same dispatch() serving live trafficThe --appendfsync policy controls how hard the server pushes those bytes
toward physical disk. Two failure modes matter and they are not the same:
- Process crash (
kill -9): Python's buffer is flushed into the OS page cache after every append, under all three policies. No policy loses data here. - Machine crash (power loss, kernel panic): only an
fsyncguarantees the page cache reached the platter. This is what the policy actually trades.
| Policy | Worst-case loss on power failure | Measured throughput | p50 latency |
|---|---|---|---|
always |
at most the single in-flight write | 743 ops/sec | 66.7 ms |
everysec (default) |
~1 second of writes | 3,143 ops/sec | 14.6 ms |
no |
whatever the OS hadn't flushed | 6,601 ops/sec | 5.9 ms |
An 8.9× throughput spread across one flag. Ordering matters as much as
the policy: the append happens before the reply is sent, so under
always a client is never told a write succeeded before it is on disk.
Crash recovery also handles the damage a crash actually causes. A power failure mid-append leaves a truncated trailing command — so replay keeps everything it parsed successfully, logs a warning, and discards the incomplete tail rather than refusing to start:
WARNING AOF: discarding incomplete trailing command (7 trailing byte(s)) - likely a crash mid-write
An append-only log grows forever. SET k 1 a million times and you have a
million entries reconstructing one key. BGREWRITEAOF replaces the log with
the minimal command set that rebuilds current state — one SET per string,
one RPUSH per list, one HSET per hash, one SADD per set, plus a
PEXPIRE for each key still carrying a TTL.
It writes to a temp file and os.replace()s it over the live one, which is
atomic on both platforms. But there's a real portability trap here, hit
during development:
POSIX lets you rename over a file that's still open elsewhere — the open descriptor keeps referencing the old inode. Windows refuses, raising
PermissionError: [WinError 5] Access is denied— including when the conflicting handle belongs to the very same process.
So the writer's handle is closed before the replace and reopened after, on
every platform. Which is also why rewrite() takes the live AOFWriter
object rather than just a path — it has to be able to close and reopen it.
A joining replica needs the leader's current state, not its entire write history — replaying a million-command log to reconstruct ten keys is absurd. So the handshake reuses the compaction machinery:
- Follower connects and sends
REPLCONF SYNC - Leader replies with a snapshot — the same minimal command set
BGREWRITEAOFproduces - Follower replays it through
replay_bytes()— again, the same dispatcher - Leader holds the connection open and streams every subsequent write to it
Verified end-to-end:
leader SET city Mumbai -> OK
leader RPUSH langs py go rust -> 3
leader HSET user name Goutam -> 1
follower GET city -> Mumbai
follower LRANGE langs 0 -1 -> ['py', 'go', 'rust']
follower HGETALL user -> ['name', 'Goutam']
follower SET x 1 -> READONLY You can't write against a read only replica.Replicas reject writes, so the leader stays the single source of truth. Failure is best-effort by design: an unreachable leader or a dropped connection is logged and ends the replication task, but the follower keeps serving reads rather than crashing.
commands.py gives every handler the uniform signature
handler(store, args) -> reply. Three commands genuinely cannot fit it, and
they're special-cased in server.py rather than contorting the abstraction:
SUBSCRIBE/UNSUBSCRIBE— one call produces several independent top-level replies (one per channel), which a single-return contract can't expressREPLCONF— needs the raw writer, to send a snapshot and then keep pushing to that same connection indefinitely
PUBLISH has no such need — it only touches shared state and returns one
ordinary reply — so it stays a normal handler. Knowing where an abstraction
should stop is part of designing one.
python -m pycache.server &
python benchmark/bench.py --connections 50 --ops 1000connections: 50
total ops: 50000 (alternating SET/GET)
throughput: 3142.5 ops/sec
latency p50: 14.593 ms
latency p95: 29.419 ms
latency p99: 47.777 ms
Measured on Windows 11, Python 3.13, 50 concurrent connections, default
appendfsync=everysec. See the durability table
for how much the fsync policy moves this.
Reading these numbers honestly: the harness is closed-loop — each of the
50 connections blocks for a reply before sending again — so throughput here
is bounded by round-trip latency, not by the store. The in-memory operations
are microseconds; the milliseconds are socket round trips, the per-append
flush() syscall, and CPython overhead. A C implementation like real Redis
is orders of magnitude faster, and the gap is mostly the runtime, not the
algorithms. --host/--port accept any RESP2 server, so you can point the
same harness at a real redis-server and compare directly.
tools/rate_limiter.py implements per-key rate limiting against a running
PyCache instance, connecting over a raw socket with the project's own codec.
with RateLimiter(limit=5, window_seconds=10) as limiter:
for i in range(1, 9):
print(i, "ALLOWED" if limiter.is_allowed("user:42") else "DENIED")1 ALLOWED 2 ALLOWED 3 ALLOWED 4 ALLOWED 5 ALLOWED
6 DENIED 7 DENIED 8 DENIED
It's a fixed-window counter, not a token bucket — and that's a reasoned choice, not a shortcut. A token bucket stores a count and a last-refill timestamp, requiring a read-then-write across two values. Without transactions or server-side scripting (neither of which this project implements), that's two network round trips and therefore racy: two clients can both read the same count, both decide there's room, and both proceed.
INCR is a single command, and per the
concurrency model it completes
atomically before any other client's command interleaves. So a fixed-window
counter cannot race. The tradeoff is accepted knowingly: traffic can burst
to limit at the end of one window and again at the start of the next.
For capping load — as opposed to smoothing it — that's the standard
real-world Redis pattern.
This is also why INCR deliberately does not clear a key's TTL the way
SET does: the limiter sets an expiry on the first hit of a window and
relies on every later increment leaving it alone.
pytest # 147 passed in ~5s
pytest tests/test_resp.py -v # one module| Suite | Tests cover |
|---|---|
test_resp.py |
Split reads, pipelining, binary payloads, malformed input, every RESP type |
test_commands.py |
Every command, arity errors, WRONGTYPE, negative indices, edge cases |
test_expiry.py |
TTL accuracy, passive vs. active expiry, PERSIST, INCR preserving TTL |
test_aof.py |
Round-trip replay, truncated tails, corrupted data, compaction correctness |
test_pubsub.py |
Fan-out, multi-subscriber delivery, unsubscribe, disconnect cleanup |
test_replication.py |
Handshake, snapshot correctness, live streaming, read-only enforcement |
Failure paths are tested as deliberately as success paths — truncated AOFs, corrupted files, protocol violations, and type mismatches all have coverage, because those are the cases that decide whether a data store is trustworthy.
Stated plainly, because pretending they don't exist would be worse:
- Relative
EXPIREin the AOF doesn't preserve the absolute deadline. The AOF stores commands literally, so replayingEXPIRE foo 100on restart resets the TTL to 100 seconds from replay time, not from when it was originally issued. Real Redis rewrites relative expiries to absolutePEXPIREATbefore persisting.BGREWRITEAOFoutput doesn't have this problem — it always writes the current remaining TTL — so compacting before a restart works around it. - Replication is best-effort. No partial resync, no replication offsets or ACKs, no automatic failover. A dropped connection ends the follower's replication task rather than reconnecting.
- Fan-out to followers is a simple loop — no batching, pipelining, or flow control.
BGREWRITEAOFblocks the event loop for its duration. Offloading it to a thread would remove the block but would then require locking around the keyspace, which the single-threaded design deliberately avoids everywhere else. Blocking briefly was the better trade.
Out of scope by design: RDB snapshots, cluster mode, RESP3, AUTH/ACLs,
Lua scripting, transactions (MULTI/EXEC).
35+ supported commands — click to expand
| Command | Notes |
|---|---|
| Connection | |
PING [message] |
+PONG, or echoes message |
ECHO message |
|
COMMAND |
Stubbed empty array so redis-cli doesn't hang on connect |
| Generic | |
TYPE key |
string / list / hash / set / none |
DEL key [key ...] |
Count deleted |
EXISTS key [key ...] |
Count present (counts duplicates) |
DBSIZE |
Raw keyspace count, no expiry side effects |
| Strings | |
SET key value [EX s | PX ms] |
EX/PX set a real TTL |
GET key |
Nil if missing |
INCR key / DECR key |
Missing key starts at 0; preserves existing TTL |
Lists (deque) |
|
LPUSH / RPUSH key value [...] |
Returns new length |
LPOP / RPOP key [count] |
Bulk string or nil; array or null-array with count |
LRANGE key start stop |
Negative indices supported |
LLEN key |
|
Hashes (dict) |
|
HSET key field value [...] |
Count of new fields |
HGET / HDEL / HEXISTS / HLEN |
|
HGETALL key |
Flat array, alternating field/value |
Sets (set) |
|
SADD key member [...] |
Count of new members |
SREM / SMEMBERS / SISMEMBER / SCARD |
|
| Expiry | |
EXPIRE key s / PEXPIRE key ms |
1 if set, 0 if key missing |
TTL key / PTTL key |
Remaining; -1 no TTL, -2 no key |
PERSIST key |
Removes TTL |
| Persistence | |
BGREWRITEAOF |
Compacts the AOF; replies immediately, runs in background |
| Pub/Sub | |
SUBSCRIBE channel [...] |
One confirmation reply per channel |
UNSUBSCRIBE [channel ...] |
No args = unsubscribe from all |
PUBLISH channel message |
Returns subscriber count reached |
| Replication | |
REPLCONF SYNC |
Follower handshake; replies with a state snapshot |
While a connection has any active subscription, only SUBSCRIBE,
UNSUBSCRIBE, and PING are accepted — matching Redis's own subscribe-mode
restriction.
pycache/
├── resp.py RESP2 encoder + incremental decoder
├── server.py asyncio connection loop, write-path ordering
├── commands.py Dispatch table and handlers
├── store.py Keyspace, types, TTL bookkeeping
├── expiry.py Active-expiry background task
├── pubsub.py Channel registry and fan-out
├── replication.py Leader registry / follower client
└── persistence/
└── aof.py Append, replay, compaction
tests/ 147 tests across 6 suites
benchmark/bench.py Async load generator, works against real Redis too
tools/rate_limiter.py Fixed-window rate limiter built on INCR + EXPIRE
MIT — see LICENSE.