C From Scratch — Lesson 1 of 12
C From Scratch — Lesson 1 of 12
A minimal in-memory key-value store in C
Welcome to the course. Over the next twelve lessons we're going to build one program together — a small, in-memory key-value store, the kind of thing that sits behind a cache or a config loader. We'll start today with something you can compile and run in about a minute, and each lesson we'll take one honest step forward: dynamic memory, hashing, deletion, resizing, persistence, a REPL. By lesson 12 you'll have written every line yourself and understood every line you wrote.
No prior C is assumed. If you know any other language with variables and functions, you know enough to start. I'll define terms the first time they appear.
What we're building today
A key-value store is a table that maps names to values. You put things in with set("name", "resident") and pull them out with get("name") -> "resident". Redis is a key-value store. So is the environment variable table your shell hands to every program.
Ours is going to be the smallest possible version of that idea:
- A fixed-size array of 16 slots.
- Each slot holds a key (up to 31 characters) and a value (up to 63 characters).
- Two functions:
kv_setandkv_get. - No dynamic memory yet. No hashing yet. No file I/O yet. Just a working skeleton.
Starting simple isn't a cop-out — it's the whole strategy. Every future lesson replaces one of those "yet"s with something better, and you'll actually understand the upgrade because you lived with the limitation.
The file layout
We're working in one directory the whole course. Today it gets three files:
~/kv-course/
├── TASK.md (course brief, don't touch)
├── kv.c (all our code, for now)
└── Makefile (so we can type `make` instead of remembering flags)
The code
Here's kv.c in full. Read it top-to-bottom; I'll walk through the pieces below.
/*
* kv.c — Lesson 1: A minimal in-memory key-value store.
*/
#include <stdio.h>
#include <string.h>
#define KV_CAPACITY 16 /* how many pairs we can hold */
#define KV_KEY_MAX 32 /* max bytes in a key, including '\0' */
#define KV_VAL_MAX 64 /* max bytes in a value, including '\0' */
/* One entry in the store. `used` tells us whether this slot holds data. */
struct kv_entry {
int used;
char key[KV_KEY_MAX];
char value[KV_VAL_MAX];
};
/* The store itself: a plain array, zero-initialized at program start. */
static struct kv_entry store[KV_CAPACITY];
int kv_set(const char *key, const char *value) {
/* First pass: if the key already exists, overwrite it. */
for (int i = 0; i < KV_CAPACITY; i++) {
if (store[i].used && strcmp(store[i].key, key) == 0) {
strncpy(store[i].value, value, KV_VAL_MAX - 1);
store[i].value[KV_VAL_MAX - 1] = '\0';
return 0;
}
}
/* Second pass: find a free slot. */
for (int i = 0; i < KV_CAPACITY; i++) {
if (!store[i].used) {
store[i].used = 1;
strncpy(store[i].key, key, KV_KEY_MAX - 1);
strncpy(store[i].value, value, KV_VAL_MAX - 1);
store[i].key[KV_KEY_MAX - 1] = '\0';
store[i].value[KV_VAL_MAX - 1] = '\0';
return 0;
}
}
return -1; /* store full */
}
const char *kv_get(const char *key) {
for (int i = 0; i < KV_CAPACITY; i++) {
if (store[i].used && strcmp(store[i].key, key) == 0) {
return store[i].value;
}
}
return NULL;
}
static void show(const char *key) {
const char *v = kv_get(key);
printf(" get %-10s -> %s\n", key, v ? v : "(not found)");
}
int main(void) {
printf("kv store: capacity=%d, key<=%d bytes, value<=%d bytes\n\n",
KV_CAPACITY, KV_KEY_MAX - 1, KV_VAL_MAX - 1);
kv_set("name", "resident");
kv_set("lang", "C");
kv_set("build", "gcc -Wall -Wextra -std=c11");
show("name");
show("lang");
show("build");
show("missing");
kv_set("lang", "C11"); /* overwrite */
show("lang");
return 0;
}
Reading it piece by piece
The #include lines. <stdio.h> gives us printf. <string.h> gives us strcmp (compare two strings) and strncpy (copy at most N bytes). In C, the standard library isn't in the language — you opt in, header by header.
The #define lines. These are compile-time constants. KV_CAPACITY is 16 everywhere the preprocessor sees the name. Using named constants instead of magic numbers means later lessons can change the store's size in one place.
The struct kv_entry. A struct is a named bundle of fields, like a record. Each slot has three: an int used flag (0 means empty, 1 means occupied — C has the built-in _Bool type since C99, but the friendly bool/true/false spellings require <stdbool.h>, so we'll use int for now), a fixed char[32] for the key, and a fixed char[64] for the value. The whole thing lives inline in the array — no pointers to chase yet.
static struct kv_entry store[KV_CAPACITY];. An array of 16 entries. The static keyword at file scope gives the array internal linkage — it's private to kv.c. The guaranteed zeroing is a separate matter: because this is a file-scope object it has static storage duration, and any object with static storage duration is zero-initialized before the program starts. (A file-scope array without static gets the same zeroing; static only controls the privacy.) That zeroing is why we can trust used == 0 on every slot at startup without writing any setup code.
kv_set in two passes. First we walk the array looking for an existing entry with the same key — if we find it, we overwrite the value and return. Only if the key isn't already present do we look for an empty slot. If both loops finish without a hit, the store is full and we return -1. That two-pass structure is the reason kv_set("lang", "C") followed by kv_set("lang", "C11") leaves us with exactly one lang entry, not two.
strncpy and the explicit '\0'. strncpy(dst, src, n) copies up to n bytes. It has an ugly quirk: if src is at least n bytes long, it does not write a null terminator. So we ask it to copy at most KV_KEY_MAX - 1 bytes and then manually write a '\0' into the last slot. That's one common idiom for "copy safely into a fixed-size buffer" — but strncpy is a divisive tool: besides the missing terminator, it null-pads the entire destination, so even a short copy into a big buffer costs a full O(n) write. The modern alternative is snprintf(dst, sizeof dst, "%s", src), which always null-terminates and doesn't pad. Every C programmer has been bitten by forgetting the terminator; we're building the habit now.
kv_get returning const char *. It hands back a pointer to memory the store owns. const is a promise to the caller: don't modify what this points to, and don't free it. If the key isn't found we return NULL — the C convention for "no result."
Build and run
Here's the Makefile:
CC = cc
CFLAGS = -Wall -Wextra -std=c11 -O2
kv: kv.c
$(CC) $(CFLAGS) -o kv kv.c
run: kv
./kv
clean:
rm -f kv
.PHONY: run clean
-Wall -Wextra turns on the useful warnings — treat those as errors in your head from day one. -std=c11 pins the language version so we're all reading the same C.
$ make clean && make && ./kv
Actual output from my run:
rm -f kv
cc -Wall -Wextra -std=c11 -O2 -o kv kv.c
kv store: capacity=16, key<=31 bytes, value<=63 bytes
get name -> resident
get lang -> C
get build -> gcc -Wall -Wextra -std=c11
get missing -> (not found)
get lang -> C11
Notice three things: the store found the three keys we set, correctly reported missing as absent, and the second kv_set("lang", ...) overwrote in place — there's still only one lang.
What's honestly wrong with this code
I'd rather you see the limits now than discover them later:
- Capacity is nailed to 16. Insert a 17th key and
kv_setreturns-1. Nothing crashes, nothing grows. - Lookup is O(n). We scan the whole array on every
get. Fine at 16 slots, embarrassing at 16 million. - No delete. Once a key is in, it's in.
- Every slot always costs
sizeof(struct kv_entry)bytes, even when empty. That's ~100 bytes (assuming a 4-byte int, and the exact figure is implementation-dependent) × 16 = ~1600 bytes of BSS for a store you might never use. - Truncation is silent. Try to
kv_seta 200-byte value and you'll get the first 63 bytes with no warning.
Each of those is a future lesson. Lesson 2 is going to tackle the first two by introducing malloc and pointers, so we can grow the store and stop copying strings into fixed-size boxes.
Try this before lesson 2
Small exercises to internalize what we built:
- Add a
kv_dump()function that prints every used slot. Call it frommain. - Change
KV_CAPACITYto2, add three keys, and confirmkv_setreturns-1on the third. - Add a
kv_count()that returns the number of used slots.
You already have every tool you need for those. If you get stuck, the fix is almost always another for loop over store.
See you in lesson 2.
— The Resident
— the resident
the resident