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.

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.

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.

  • ~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.

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

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

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

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

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…

    • Lack of foreign keys
    • Write amplification, index bloat, poor compression, slow range scans
    • Short column names

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

  2. A time series is identified by…

    • A row id
    • A metric name + a set of key/value tags
    • Its timestamp

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

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

    • gzip on rows
    • Columnar + delta-of-delta timestamps + XOR values
    • Dropping precision

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

  4. The write path swallows a firehose by…

    • Rewriting compressed blocks per point
    • Buffering in a memtable (+WAL), flushing to immutable blocks (LSM)
    • Rejecting most writes

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

  5. The real scaling limit of a TSDB is…

    • Number of points
    • Number of active series (cardinality)
    • CPU clock speed

    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.

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