Vibe Engines
YouTube
System Design

Design a Time-Series Database

Step 1 / 9

Learn system design by building a time-series database like InfluxDB, Prometheus TSDB or TimescaleDB step by step.

The numbers to beat~16→~1.5bytes per pointdelta²timestampsXORvalues

The whole design, in writing

Learn system design by building a time-series database like InfluxDB, Prometheus TSDB or TimescaleDB step by step. An interactive guide covering why a normal SQL table melts under metrics, the series = metric + tags data model, append-only columnar storage with delta and XOR compression, an LSM write path with a WAL, sharding by series, downsampling and retention for cheap long-range reads, the cardinality problem and inverted index, and the unhappy paths (late data, hot series).

Every step of the build above, written out: the problem each piece solves, the option that was taken and the ones that were not, the numbers, and how it fails in production.

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.

Agents · Dashboardswrite · query
New in this step: Agents · Dashboards.

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.

What the new pieces do

Agents · Dashboardsclient
Thousands of exporters/agents streaming metrics in, and dashboards/alerts querying time ranges out. Write-heavy, read-bursty.

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?

Agents · Dashboardswrite · query
The system as it stands at this step.

A row-per-point SQL table at 1M points/sec. What breaks?

  1. Indexes make it worse: every insert updates a B-tree, and the index itself becomes enormous. Row storage also can't compress the highly-regular data well. It breaks on write throughput, storage size and range-scan speed at once.

  2. It's not "a little" — it's write amplification from B-tree index maintenance, huge uncompressed row storage, and range scans reading vast amounts of unneeded columns. All three are fundamental to the row model here.

  3. 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.

Why this piece earns its place

The fair version of the cheaper option isn't a naive table — it's a relational store partitioned by time, with a block-range index over the timestamp instead of a B-tree. That genuinely fixes two of the four complaints: the index stops bloating, because a block-range index over already-ordered data is tiny, and the write amplification goes with it, because there is no per-row B-tree left to maintain — retention gets cheaper too, since expiring old data becomes dropping a whole partition rather than a delete storm. For a modest fleet that is the right call, and I'd rather operate it than a TSDB I have to learn. Where it stops is the two things partitioning can't touch: the per-row overhead and the tag columns repeated on every single point, and the row-major layout that reads the whole row to aggregate one column. Those are properties of the storage format, not of how you slice it. So the honest trigger for a purpose-built engine isn't the word "metrics" — it's the point where bytes per point and scan width, rather than query planning, are what's actually hurting.

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?

Agents · Dashboardswrite · queryIngestwrite path
New in this step: Ingest.

How do you identify the millions of distinct metric streams?

  1. Millions of tables is unmanageable and can't be queried across. The standard model is a single logical space of series identified by a metric name plus a set of key/value tags.

  2. 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.

  3. JSON per point is huge and unqueryable at scale. The compact model is a typed metric+tags identity with numeric points, which compresses and indexes beautifully.

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.

Why this piece earns its place

This model quietly assumes a series identity never changes, but the tag set is chosen by whoever emits the metric, and they will change it. Add a version tag to an existing metric and you haven't annotated a series, you've minted a new one: the old series stops, a new one starts, and anything keyed to the whole identity breaks at the deploy. For the length of a rolling deploy both exist, so a naive sum across them double-counts while a naive rate sees a series that just ended. The defense is to treat the tag set as a schema. Queries should aggregate with an explicit group-by over the tags they actually care about, rather than pinning a full identity, so an added tag passes through invisibly. Then review tag changes the way you'd review a column, and where a rename is unavoidable, emit both series for at least a dashboard window instead of cutting over in one deploy.

What the new pieces do

Ingestgateway
The write endpoint. Accepts a firehose of (series, timestamp, value) points and lands them durably at very high throughput.

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?

Columnar Shard Atime blocks
New in this step: Columnar Shard A.

Points are timestamp+value at huge volume, but very regular. How do you store them small?

  1. Compressing rows helps a little but mixes timestamps and values and different series together, killing the ratio. Storing each column separately lets you exploit the specific regularity of timestamps and of values.

  2. At terabytes/day the storage (and the scan cost) is the problem, not a shortage of disks. Specialized encodings cut it 10×+, which also makes reads faster — you can't buy your way out of the scan cost.

  3. 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.

Why this piece earns its place

Both encodings are bets on regularity, and the block size is where you settle them. Delta-of-delta collapses to a bit only while intervals stay near-fixed — let scrapes jitter, or an agent batch and replay, and the timestamp column stops being nearly free. XOR makes the same bet on values: a counter ticking up in small steps or a flat gauge packs beautifully, but a genuinely noisy full-precision float shares almost no bits with its predecessor and can cost more than the raw 8 bytes. So ~1–2 bytes a point is a fleet average, not a per-series guarantee, and bytes-per-point is worth tracking per metric, because the metrics that blow it are usually the mismodelled ones. Block size is the dial on top of that. These are chained bit streams, so you cannot seek into one — a read decodes from the start of the block. Long blocks amortise headers and compress better; short blocks let a narrow query decode less and let retention expire at a finer grain.

  • ~16→~1.5bytes per point
  • delta²timestamps
  • XORvalues

What the new pieces do

Columnar Shard Adata
Compressed, time-partitioned columns of timestamps and values. Reads scan only the blocks in range.

Back of the envelope

columnar, time-partitioned blocks
scan only the range + column
delta-of-delta timestamps
regular intervals → bits
XOR-compressed floats
small changes → mostly zeros

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?

Ingestwrite pathWAL + Memtablerecent pointsColumnar Shard Atime blocks
New in this step: WAL + Memtable.

You can't rewrite compressed blocks per point, but points flood in. How do you ingest?

  1. Rewriting a compressed, immutable block per point is impossibly slow. The LSM pattern buffers writes in memory and flushes them in batches to new immutable blocks instead.

  2. 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.

  3. Blocking writers stalls the firehose and risks data loss. You accept every point immediately into memory (durably, via the WAL) and flush asynchronously — never make the producer wait.

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.

Why this piece earns its place

What this buys in throughput it pays for in memory, and the bill is sized by the wrong variable. The in-memory head holds an open block for every active series at once, so RAM tracks the number of series being written, not the points per second — a fleet that doubles its scrape rate barely moves it, and one that doubles its tag combinations doubles it. Flushing is the other edge, and it's a cliff rather than a trickle: compressing and writing a whole block spikes CPU and disk in one instant, and while it runs the old head is still resident alongside what's being written, so peak memory is the number to size against, not the steady state. Two cheap defenses: stagger flush boundaries across shards so they never land together, and keep the flush interval short enough that WAL replay after a crash stays inside an acceptable restart time — the log only truncates once a flush completes, so a long interval is a long, quiet recovery.

  • memtablein-RAM head
  • WALcrash-safe
  • flush→ immutable block

What the new pieces do

WAL + Memtablecontrol
New points buffer in memory (fast) with a write-ahead log for durability, then flush to immutable columnar blocks.

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?

Columnar Shard Btime blocks
New in this step: Columnar Shard B.

One node can't ingest/store everything. How do you partition?

  1. Partitioning only by time means all current writes hammer a single "now" node (a hotspot), while older nodes sit idle. You want writes spread across nodes at all times.

  2. Scattering a single series's points across all nodes destroys locality — a range query for one series would gather from everywhere. Keep each series together and spread whole series across nodes.

  3. 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.)

Why this piece earns its place

Hashing the identity assumes the map from series to shard never moves, and it moves the first time you add a node. A plain modulo over the node count relocates nearly every series, and relocating a series here means copying its history, precisely while the cluster is short of capacity — which is why you were adding a node. So fix a large shard count up front and make the shard, not the series, the unit that moves: hash into many virtual shards, assign shards to nodes, and rebalancing becomes handing over whole shards. Time-blocking then offers a second, cheaper option you already have: cut over at a block boundary, so new blocks land on the new owner while old blocks stay put and simply age out under retention. Nothing is copied at all — the cost is that a query for that series reads from two owners until the old blocks expire.

  • hash(series)even write spread
  • time blockscheap retention
  • replicateddurable shards

What the new pieces do

Columnar Shard Bdata
Another shard. Series are partitioned across shards so ingest and storage scale horizontally.

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?

Query EngineColumnar Shard BRollup / CompactCold / Downsampled
New in this step: Query Engine, Rollup / Compact, Cold / Downsampled. · swipe to pan the diagram

A "last year" query would scan billions of raw points. How do you make it fast + bounded?

  1. Reading billions of points to draw a chart is slow and expensive, and repeats the work on every refresh. Pre-aggregate into coarser resolutions so a long-range query reads a tiny rolled-up series.

  2. 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.

  3. Caching a rendered chart doesn't help arbitrary new ranges or queries and goes stale. The durable fix is pre-aggregated rollups plus retention, which speed up all long-range queries and cap storage.

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.

Why this piece earns its place

Picking the rollup intervals is the easy half. What you store in each bucket is where long-range queries are quietly decided, because a rollup is lossy on purpose and you learn which question you gave up much later. Min, max, sum and count are safe, because they re-aggregate: an hour composes from its minutes, and an average is recovered as sum over count. An average alone does not compose — averaging averages weights the buckets wrongly unless you kept the counts. A percentile composes from nothing at all: a p99 over 6 hours cannot be derived from stored per-minute p99s, so if you promise percentiles at long range you must roll up a sketch rather than a number, and pay its size in every bucket. Counters have the same trap a level down — a rate has to be computed before the rollup, because the coarse series can no longer see the resets. Decide all of this when you define the rollup, since the raw points that would let you change your mind are exactly what retention deletes.

  • rollups1m / 1h / 1d
  • retentionraw days · rolled years
  • 1000×fewer points scanned

What the new pieces do

Query Enginegateway
Resolves which series match a query's tags, scans the right time blocks, and aggregates (rate, avg, p99) over the range.
Rollup / Compactservice
Background jobs that pre-aggregate raw points into coarser resolutions and compact/expire old blocks.
Cold / Downsampledcold
Older, downsampled data on cheaper storage. Long-range queries hit this small rolled-up series, not raw points.

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.

IngestshardedQuery Engineread pathColumnar Shard Atime blocksColumnar Shard Btime blocksSeries Indextags → series
New in this step: Series Index.

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.

Why this piece earns its place

"Bounded" has to mean bounded across the retention window, not bounded right now, and that distinction is what actually bites. A pod name, a container id, a build version, an autoscaling instance id — each is a small set at any instant, a handful of values that look obviously safe on a dashboard, and every deploy retires that set and mints a fresh one. The live series count stays flat while the total the index must carry climbs with deploy frequency, so the teams that ship most often break it first, and the wall gets hit by a tag nobody would have flagged as unbounded. The number worth alerting on is therefore new series per hour, not active series: the rate is what predicts the wall, while the total only tells you that you already hit it. The usual fix is to aggregate the churning dimension away at ingest — keep the deployment, drop the pod — and to give churn-heavy metrics shorter retention than the stable ones.

  • inverted indextags → series ids
  • bounded tagsno user/request ids
  • #seriesthe scaling limit

What the new pieces do

Series Indexservice
An inverted index mapping tag values (region="eu") to the series ids that match — how queries find series fast.

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.

Agents · DashboardsIngestQuery EngineWAL + MemtableColumnar Shard AColumnar Shard BSeries IndexRollup / CompactCold / Downsampled
The system as it stands at this step. · swipe to pan the diagram

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.

Why this piece earns its place

The look-back window is the knob here, and it fails quietly in both directions. Too short, and a lagging agent's backlog is refused on arrival: the series has a hole, the chart draws a flat line across it, and nothing in the picture distinguishes dropped data from a steady value. So count and expose rejected-as-too-late per writer — the silent drop is the failure, not the drop. Too long, and the cost lands on everything upstream: the block covering that range must stay open, pinning memory and deferring compression, and any bucket the rollup jobs already aggregated is now wrong, so a late point either forces a recompute of that bucket or is accepted into raw data the coarse series will never reflect. That second outcome is the nastier one, because raw and rolled-up answers to the same question start disagreeing. Set the window from the measured lateness of your agents, keep it short, and give genuine backfill its own path rather than widening the window for everyone.

You did it

You just designed a time-series database.

Agents · DashboardsIngestQuery EngineWAL + MemtableColumnar Shard AColumnar Shard BSeries IndexRollup / CompactCold / Downsampled
The finished design, end to end. · swipe to pan the diagram

Everything you assembled, in order

  • A row-per-point SQL table melts on write amplification, index bloat, poor compression and slow range scans.
  • Model data as series (metric + bounded tags) with ordered (timestamp, value) points.
  • Columnar storage + delta-of-delta timestamps + XOR values shrink ~16 bytes to ~1–2 per point.
  • An LSM write path (memtable + WAL → immutable compressed blocks) swallows the firehose durably.
  • Shard by series for even write spread + locality; time-block within a node for cheap retention.
  • Downsampling/rollups + retention make long-range reads fast and storage bounded.
  • An inverted index makes tag queries fast; bounded cardinality is the real scaling limit.

Where an interviewer pokes next

Getting the boxes right is the easy half. These are the questions that separate a candidate who drew the diagram from one who has run the thing. Answer each one out loud before you open it.

  1. Why can't a normal relational database handle metrics at scale?

    A general row-store is optimized for transactional reads and updates of individual rows, not for a firehose of append-only points queried by time range. Three things break at metric volume. First, write amplification: maintaining a B-tree index on timestamp (and on tags) means every one of a million-per-second inserts does index work, and the index itself grows enormous. Second, storage: row storage interleaves timestamps, values, and tags and compresses the highly-regular numeric data poorly, so you store far more bytes than necessary. Third, reads: a range scan in a row store drags whole rows (all columns) off disk even when you only need one value column, and without time partitioning it can't skip irrelevant data. A purpose-built TSDB fixes all three with append-only LSM ingest, columnar time-partitioned storage, specialized compression, and downsampling — it's a different engine tuned to a different access pattern.

  2. How does a TSDB compress ~16 bytes per point down to ~1–2 bytes?

    By storing timestamps and values in separate columns and exploiting how regular each is. Timestamps in a series usually arrive at a near-fixed interval (say every 10 seconds), so instead of storing the full 8-byte timestamp you store the delta between consecutive timestamps — and because that delta is nearly constant, you store the delta-of-the-delta, which is usually zero and packs into a single bit. Values are compressed with XOR: consecutive floating-point readings tend to be close, so XOR-ing a value with the previous one yields a result that's mostly leading and trailing zero bits, which you encode compactly by recording only the meaningful middle bits. This is the Gorilla scheme popularized by Facebook and used by Prometheus; on typical metrics it brings the ~16 bytes of a raw (timestamp, double) point down to often around 1–2 bytes, which both slashes storage and makes range scans far faster because there's simply less data to read.

  3. What is the cardinality problem and why is it the number-one TSDB killer?

    A series is uniquely identified by its metric name plus the exact set of tag key/values, so every distinct combination of tag values is a separate series with its own in-memory state and index entries. A TSDB's memory footprint and index size scale with the number of active series, not with the number of points — so the danger isn't volume of data, it's variety of series. If you put a high-cardinality value into a tag — a user ID, a request/trace ID, an email, a full URL, a raw timestamp — you mint a brand-new series for every distinct value, and the series count can explode into the millions or billions, blowing up memory and the inverted index until the database falls over. The discipline is to keep tag values bounded and low-cardinality (region, status code, method, host), enforce per-metric series limits, and push genuinely high-cardinality identifiers into logs or the value field rather than into tags.

  4. Why shard by series instead of by time?

    It's tempting to give each node a time window, but that creates a brutal hotspot: since almost all writes are for "now," a single node owning the current window absorbs the entire write firehose while every node holding older windows sits idle — and when the window rolls over, the hotspot just moves to the next node. Sharding by the series identity (hashing metric+tags) instead spreads the write load evenly across all nodes at all times, because different series land on different shards regardless of time. It also preserves locality: all of a given series's points stay together on one shard, so a range query for that series is a local scan rather than a scatter-gather across the cluster. Time is still used, but as a secondary split within each shard — data is chunked into time blocks so that retention and compaction can cheaply drop or move whole old blocks.

  5. How do rollups and retention keep both queries and storage under control?

    The insight is that resolution should follow the query range: no one needs per-second granularity across a year, because a chart only has on the order of a thousand pixels. Rollup (downsampling) jobs run in the background and pre-aggregate raw points into progressively coarser series — for example 1-minute, 1-hour, and 1-day buckets storing min/max/avg/count — so a long-range query reads the coarse series and scans orders of magnitude fewer points than the raw data would require, turning a billion-point scan into a few thousand. Retention policies then bound storage by keeping full-resolution raw data only for a short recent window (days), keeping downsampled data for much longer (months or years), and dropping or archiving anything past its policy. Because data is stored in immutable time blocks, expiring old data is as cheap as deleting whole blocks. Together, rollups make long-range reads fast and retention makes total storage bounded and predictable — without them, a TSDB slowly grinds to a halt and fills its disks.

Check yourself — the answers, and why

Nine steps in, these are the calls you should be able to make cold. Pick one, then read why.

  1. A row-per-point SQL table fails at metric scale mainly due to…

    The append-only, range-queried workload wants a columnar, compressed, time-partitioned engine.

  2. A time series is identified by…

    Tags both identify the series and define how you filter/aggregate across series.

  3. Points shrink to ~1–2 bytes via…

    Regular timestamps compress to bits; near-constant floats XOR to mostly zeros (Gorilla).

  4. The write path swallows a firehose by…

    Append to in-memory head durably, flush/compact to immutable columnar blocks in the background.

  5. The real scaling limit of a TSDB is…

    Memory/index scale with distinct series, so unbounded tag values (ids) blow it up.

How you’d open this design in an interview

Before any boxes: agree what it must do, pin the qualities that shape everything, then build — naming each trade-off as you make it. The walkthrough above is that exact order.

What it must do

Agree on these before drawing a single box.

  • Ingest: accept a firehose of (series, timestamp, value) points at very high throughput.
  • Query a range: aggregate (rate, avg, p99) over a time window in milliseconds.
  • Slice by tags: filter and group series by tag — region="eu", status=500.
  • Retain & expire: keep raw data for days, downsampled data for months/years, drop the rest.
  • Durability: never lose recently-ingested points, even on a crash.

The qualities that shape everything

Each one names the mechanism that buys it.

Swallow a million points a second
An LSM-style path appends points to an in-memory memtable and flushes to new immutable columnar blocks — never rewrite a compressed block in place.
Store terabytes/day in a fraction of the space
Columnar layout with delta-of-delta timestamps and XOR-compressed floats shrinks ~16 bytes to ~1–2 bytes per point, and scans read only the needed column.
Lose nothing on a crash
A write-ahead log makes the in-memory head durable; un-flushed points are replayed from it after a restart.
Spread write load and keep series local
Shard by a hash of metric+tags so writes spread evenly across nodes, with time-blocking within each node for cheap retention.
Cheap long-range reads, bounded storage
Background rollups pre-aggregate raw points into coarser resolutions, and retention drops or archives old raw data.
Fast tag queries without exploding
An inverted index maps tag key=value to series ids; bounded, low-cardinality tags (no user/request ids) keep that index affordable.

The trade-offs you say out loud

Senior signal isn’t the boxes — it’s naming what you gave up and why it was the right price.

A purpose-built columnar engine over a SQL row-store with a time index

Per-point B-tree maintenance causes write amplification, the time index bloats, rows compress the regular numbers poorly, and range scans drag whole rows off disk. The append-only, range-queried workload wants a different engine.

Columnar + delta-of-delta + XOR over row storage with gzip

gzip over rows mixes timestamps, values and different series together and kills the ratio. Per-column encodings exploit near-fixed intervals (delta²→bits) and near-constant floats (XOR→mostly zeros) to reach ~1–2 bytes/point.

LSM append + flush over updating compressed blocks in place

Rewriting an immutable, compressed block for every incoming point is impossibly slow. Buffer in a memtable (durable via the WAL) and flush sorted immutable runs in the background instead.

Shard by series over sharding by time window

A time-window shard makes all “now” writes hammer one node while older nodes idle, and the hotspot just moves as the window rolls. Hashing the series identity spreads writes evenly and keeps each series together for range scans.

Pre-computed rollups + retention over scanning raw points every query

Reading billions of raw points to draw a chart is slow and repeats work on every refresh. A coarse downsampled series scans orders of magnitude fewer points, and retention bounds storage by dropping old raw data.

The answer, out loud

What a strong answer to “Design a Time-Series Database” sounds like, first question to last trade-off. It is about 7 minutes of talking; the whiteboard and the interviewer fill the rest of the 45. Read it aloud once, then close the page and give it yourself.

  1. 0–4 min

    Scope the workload first

    Let me scope it before I draw anything. I'm designing a database for one shape of data: timestamped numbers from agents, servers and sensors. Writes are almost entirely appends at now, never updates of old rows, and reads are range aggregations over a window — p99 latency for the EU region over the last six hours is the canonical one. I'd aim at a million points a second on ingest with millisecond answers on those queries, and I'd ask early how long data has to be kept, because retention shapes the storage design more than the write rate does.

  2. 4–9 min

    Why not a SQL table

    The first thing usually worth addressing is why this can't just be a table with an index on time. If I store a row per point, every one of those million inserts a second does index work, the index itself grows enormous, the tags and row overhead repeat on every point, and a range scan drags whole rows off disk when I only wanted one column. Those aren't tuning problems I can configure away, they're what happens when the row model meets an append-only, range-queried workload. So I'll build something narrower on purpose: append-only, columnar, compressed, partitioned by time.

    Built in step 1: A normal table melts
  3. 9–15 min

    What a series is

    The data model comes next, because everything hangs off it. CPU usage isn't one stream, it's one per host, per core, per data centre. So I identify a series by a metric name plus a set of key-value tags, and a series is then just an ordered stream of timestamp-value pairs. Tags are what let me ask for all cores on one host, or a sum across regions, without a table per stream. I'd flag immediately that tags are also the dangerous part, because every distinct combination of tag values is a separate series, and that I'll come back to it.

    Built in step 2: Series = metric + tags
  4. 15–22 min

    Make a point tiny

    Storage is where the leverage is. A timestamp and a number stored naively is about sixteen bytes, and at a million a second that's terabytes a day. But this data is extremely regular, so I store columns rather than rows, in time-partitioned blocks. Timestamps arrive at near-fixed intervals, so I store the difference between them, and then the difference of that difference, which is usually zero and packs into a single bit. Consecutive values barely change, so I XOR each with the previous one and get mostly zero bits. That's the Gorilla approach, and it takes those sixteen bytes down to often one or two.

    Built in step 3: Columnar + compression
  5. 22–28 min

    The write path

    Those blocks are compressed and immutable, which is wonderful to read and impossible to write into a point at a time. So the write path is log-structured. A point appends to an in-memory buffer sorted by time, and the same write goes to a write-ahead log so a crash loses nothing. Periodically that buffer is flushed and compressed into a new immutable block, and a read merges the in-memory head with the blocks underneath it. Ingest stays sequential and cheap, storage stays compressed, and nothing is ever rewritten in place.

    Built in step 4: The LSM write path
  6. 28–34 min

    Spreading it across nodes

    One node won't hold a fleet, so I partition. The tempting answer is by time, a node per window, and it's wrong: nearly every write is for now, so one node absorbs the whole firehose while the others idle, and the hotspot just walks along as the window rolls. I hash the series identity instead, so writes spread evenly and all of a series's points stay together for a range scan. Time still matters, but as a split inside each node — data sits in time blocks, which is what makes expiring old data cheap.

    Built in step 5: Shard by series
  7. 34–39 min

    Long ranges and retention

    Long ranges need a different trick. A year of per-second data is billions of points drawn onto maybe a thousand pixels of screen, so I pre-aggregate: background jobs roll raw points up into one-minute, one-hour and one-day buckets, and a long query reads the coarse series instead. Retention then bounds storage — raw for days, rolled up for months or years, the rest dropped or archived. Resolution follows range, and that single idea pays for both query speed and storage cost.

    Built in step 6: Downsampling & retention
  8. 39–43 min

    Finding series, and the real ceiling

    The last piece is how a query finds series, and it's also what kills these systems. An inverted index maps each tag key and value to the series ids carrying it, so filtering on region and status is intersecting a couple of posting lists. But memory and index size scale with the number of active series, not the number of points — ten tags with ten values each is ten billion possible series. So the line I'd hold is that tag values are bounded sets: region, status, method. User ids, request ids and URLs belong in the value or in logs, never in a tag.

    Built in step 7: Tags, cardinality & the index
  9. 43–45 min

    The trade-off, and what is left

    To close: the trade-off I've taken throughout is specialisation. I gave up updates, joins and arbitrary queries to buy compression, ingest rate and cheap range scans, and I gave up fidelity on old data to keep long queries and storage affordable. With more time I'd spend it on the messy edges — points that arrive late, one series getting hammered, how compaction is scheduled — and on making cardinality visible to the people creating it, since that's the failure I'd actually be paged for.

What this teaches

Learn system design by building a time-series database like InfluxDB, Prometheus TSDB or TimescaleDB step by step. An interactive guide covering why a normal SQL table melts under metrics, the series = metric + tags data model, append-only columnar storage with delta and XOR compression, an LSM write path with a WAL, sharding by series, downsampling and retention for cheap long-range reads, the cardinality problem and inverted index, and the unhappy paths (late data, hot series).

Key takeaways

  • A row-per-point SQL table melts on write amplification, index bloat, poor compression and slow range scans.
  • Model data as series (metric + bounded tags) with ordered (timestamp, value) points.
  • Columnar storage + delta-of-delta timestamps + XOR values shrink ~16 bytes to ~1–2 per point.
  • An LSM write path (memtable + WAL → immutable compressed blocks) swallows the firehose durably.
  • Shard by series for even write spread + locality; time-block within a node for cheap retention.
  • Downsampling/rollups + retention make long-range reads fast and storage bounded.
  • An inverted index makes tag queries fast; bounded cardinality is the real scaling limit.

Concepts covered

  • What is a time-series database?
  • A normal table melts
  • Series = metric + tags
  • Columnar + compression
  • The LSM write path
  • Shard by series
  • Downsampling & retention
  • Tags, cardinality & the index
  • Late data, hot series & compaction
built to swallow a million points a second and answer in milliseconds — make the calls, kill the rollups, run the gauntlet.
Finished this one? 0 / 65 System Designs done

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.

More System Designs