Design a Time-Series Database — the walkthrough in full
A written version of the interactive walkthrough above — the same steps, decisions and trade-offs, laid out for reading, reference and search.
The big idea
What is a time-series database?
Every server, container, sensor and app emits a constant stream of timestamped numbers: CPU every second, requests per second, temperature, price ticks. You need to ingest millions of points per second, keep them for months, and answer "p99 latency for the EU region over the last 6 hours" in milliseconds.
A time-series database is built for exactly this shape: append-mostly writes of (series, timestamp, value), and reads that are time-range aggregations. By specializing — columnar layout, aggressive compression, time partitioning, and downsampling — it does what a general SQL table simply can't at this volume.
How to read this: Each step opens with a real design decision — make the call before I show you what ships. Watch the diagram grow, and at the end kill the WAL and disable rollups to see what makes ingest durable and reads cheap. Hit Begin.
Step 1 · Why not just SQL?
A normal table melts
The obvious approach: one big table, a row per data point (metric, tags, timestamp, value), indexed on time. Insert every point, query with WHERE time BETWEEN …. At a million points a second, why does this fall apart?
Design decision: A row-per-point SQL table at 1M points/sec. What breaks?
The call: Write amplification, index bloat, poor compression and slow wide range scans. — B-tree inserts thrash on every point, the time index balloons, row storage compresses the regular data poorly, and a range scan drags whole rows off disk. The workload wants append-only, columnar, compressed, time-partitioned storage — a different engine.
A general row-store fights this workload. Per-point B-tree index maintenance causes write amplification; the time index bloats; row storage compresses the highly-regular numbers poorly; and range scans drag entire rows off disk. Time-series data is append-only, enormously repetitive, and queried by range — it wants a purpose-built engine.
Know your access pattern: Writes are almost always appends at "now", never random updates. Reads are range scans + aggregation over one or many series. Old data is read less and less. Every design choice ahead exploits these facts — that's what a specialized database is.
Step 2 · The shape of the data
Series = metric + tags
Before storing anything, model the data. "CPU usage" isn't one stream — it's one per host, per core, per data center. How do you identify the millions of distinct streams without a table per one?
Design decision: How do you identify the millions of distinct metric streams?
The call: A series = metric name + a set of key/value tags; points are (timestamp, value). — cpu_usage{host="a",core="0"} is one series; each series is an ordered stream of (timestamp, value) points. Tags let you slice and aggregate ("all cores on host a", "all hosts in eu") without separate tables.
Model a series as a metric name + a set of key/value tags (labels): http_requests{method="GET", region="eu"}. Each series is an ordered stream of (timestamp, value) points. Tags are what let you slice and aggregate across series — "sum over all regions", "p99 for method=GET" — without a table per stream.
Tags are the query surface: The tag set both identifies a series and defines how you can group and filter it. This is powerful — and dangerous: every distinct combination of tag values is a new series. That "cardinality" is the thing you'll spend the most effort controlling (step 7).
Step 3 · Store it small
Columnar + compression
A point is basically a timestamp and a number. Stored naively that's ~16 bytes each; at a million per second that's terabytes a day. But the data is astonishingly regular. How do you shrink it 10× or more?
Design decision: Points are timestamp+value at huge volume, but very regular. How do you store them small?
The call: Columnar layout with delta-of-delta timestamps and XOR-compressed values. — Store timestamps and values in separate columns per time block. Timestamps arrive at near-fixed intervals, so delta-of-delta encoding shrinks them to bits; consecutive float values barely change, so XOR-ing them leaves mostly zeros to pack. This is the Gorilla/Prometheus approach — often ~1–2 bytes per point.
Store data columnar, in time-partitioned blocks, and compress each column with encodings tuned to it. Timestamps arrive at near-regular intervals, so delta-of-delta encoding reduces them to a few bits. Consecutive float values change little, so XOR-ing adjacent values leaves leading/trailing zeros you can pack. Facebook's Gorilla approach gets from ~16 bytes to often ~1–2 bytes per point — and columnar scans read only the needed column.
Delta-of-delta & XOR: Timestamps: if points are 10s apart, the delta is 10 and the delta of the delta is 0 — store a single bit. Values: XOR a float with the previous one; when they're close, the result is mostly zero bits that pack tightly. Regular data → tiny storage → fast scans.
Step 4 · Swallow the firehose
The LSM write path
Compressed columnar blocks are great for reading, but you can't rewrite an immutable, compressed block on every incoming point. Yet points arrive by the million per second. How do you ingest fast and keep data durable and readable?
Design decision: You can't rewrite compressed blocks per point, but points flood in. How do you ingest?
The call: Buffer recent points in an in-memory memtable (with a WAL), then flush to immutable blocks. — New points append to a fast in-memory structure, made durable by a write-ahead log. Periodically the buffer is flushed and compressed into a new immutable columnar block. Reads merge the memtable with on-disk blocks. This is the LSM-tree pattern.
Use an LSM-style path. New points append to an in-memory memtable (fast, sorted by time), made durable by a write-ahead log so a crash loses nothing. Periodically the memtable is flushed and compressed into a new immutable columnar block on disk. Reads merge the in-memory head with the on-disk blocks. Ingest stays sequential and cheap; storage stays compressed and immutable.
LSM = append + flush + compact: Log-structured merge trees turn random writes into sequential appends: buffer in memory, flush sorted immutable runs, and compact them in the background. It's the same engine idea behind Cassandra and RocksDB — ideal for write-heavy, append-only workloads like metrics.
Step 5 · Beyond one node
Shard by series
One node's memory and disk can't hold or ingest every series in a large fleet. You need to spread both the write load and the stored data across many nodes. What's the partition key?
Design decision: One node can't ingest/store everything. How do you partition?
The call: By series (hash of metric+tags), often combined with time blocks. — Hashing the series identity spreads write load evenly across nodes and keeps each series's points together for fast range scans. Within a node, data is still chunked into time blocks so old blocks can be dropped or moved cheaply.
Partition by series — hash the metric+tags identity to a shard — so write load spreads evenly across nodes and each series's points stay together for fast range scans. Within each shard, keep data in time blocks so expiring or moving old data is just dropping whole blocks. (Replicate shards for durability, as any distributed store must.)
Series-sharding + time-blocking: Two orthogonal splits: across nodes by series (balance load, keep a series local) and within a node by time (cheap retention and compaction). Partitioning purely by time creates a "hot now" node; sharding by series avoids it.
Step 6 · Cheap long-range reads
Downsampling & retention
A dashboard asking for "CPU over the last year" at 1-second resolution would scan billions of raw points to draw a few hundred pixels. And keeping every raw point forever is ruinously expensive. How do you make long ranges fast and storage bounded?
Design decision: A "last year" query would scan billions of raw points. How do you make it fast + bounded?
The call: Pre-compute rollups (downsampled series) and expire raw data by retention. — Background jobs roll raw points up into 1-min, 1-hour, 1-day aggregates; a long-range query reads the coarse series and scans orders of magnitude fewer points. Retention policies then drop or archive old raw data so storage stays bounded.
Downsample: background rollup jobs pre-aggregate raw points into coarser resolutions (1-minute, 1-hour, 1-day) storing min/max/avg/count. A long-range query reads the coarse series and touches orders of magnitude fewer points. Pair this with retention: keep raw data for days, downsampled data for months/years, and drop or archive the rest — bounding storage while preserving history.
Resolution follows range: You never need per-second detail across a year — a screen has ~1000 pixels. Match stored resolution to query range: raw for recent, rolled-up for old. This is the single biggest lever for both query speed and storage cost.
Step 7 · The cardinality wall
Tags, cardinality & the index
Queries filter by tags ("region=eu, status=500"), which needs a fast tag→series lookup. But there's a trap: put a high-cardinality value in a tag — a user ID, a request ID, a timestamp — and you create a new series per value, exploding memory and the index. This is the number-one way to kill a TSDB.
Build an inverted index mapping each tag key=value to the set of series ids that carry it, so a query intersects a few posting lists to find matching series instantly. Then defend cardinality: tags must be bounded sets (region, status, method) — never unbounded ids. Enforce limits, drop or reject offending series, and put truly high-cardinality identifiers in the value or in logs, not in tags.
Cardinality is the real limit: A TSDB's memory and index size scale with the number of active series, not the number of points. 10 tags with 10 values each is 10 billion possible series. Controlling cardinality — keeping tag values bounded — is the single most important operational discipline. The inverted index makes tag queries fast; bounded tags keep that index affordable.
Step 8 · The sharp edges
Late data, hot series & compaction
Reality intrudes: points arrive out of order or late (a lagging agent), one series gets hammered (a hot series), and immutable blocks pile up needing housekeeping.
Handle out-of-order/late writes with a bounded look-back window that can still land in the current block (or a dedicated catch-up path), rejecting points too old to matter. Spread hot series by good shard hashing and cache their recent head. Run background compaction to merge small blocks into larger ones and apply rollups/retention. And guard writers with rate limits and cardinality caps so one misbehaving exporter can't take the cluster down.
Design for the unhappy path: Late point → bounded look-back, else reject. Hot series → hash-spread + cache. Block sprawl → compaction. Exploding tags → cardinality caps. The compressed, immutable, time-partitioned core needs these guards to survive real, messy telemetry.
You did it
You just designed a time-series database.
- A —
- M — o
- C — o
- A — n
- S — h
- D — o
- A — n