Data where the timestamp is the primary axis. It arrives constantly, is never updated, is queried by range, and becomes worthless with age — which is a specific enough profile to deserve its own kind of database.
Prerequisites: Storage Engines, NoSQL Modeling Time to read: ~18 minutes
You’re storing server metrics. 10,000 servers, 100 metrics each, every 10 seconds.
📐 The arithmetic:
10,000 servers × 100 metrics ÷ 10 s = 100,000 writes/second
Per year: 3.15 × 10¹² data points
At ~50 bytes per row (timestamp, metric name, value, tags) in Postgres:
157 TB/year — before indexes
And the access pattern is peculiar:
🚨 A general-purpose database is a poor fit for all four properties. A B-tree index on a
constantly-appended timestamp column, 157 TB of storage, and GROUP BY time_bucket over billions of
rows is a losing combination. Hence a specialized category.
Data is automatically split into chunks by time — a partition per day, per week, per month.
Why this matters more than it sounds:
DROP TABLE, not a DELETE. Instant, no vacuum, no tombstones, no
index churn. This alone is transformative — deleting a billion rows from a normal table is a
multi-hour operation that bloats everything.Time-series data compresses spectacularly because consecutive values are similar.
Delta encoding: store the difference, not the value.
Timestamps: 1700000000, 1700000010, 1700000020, 1700000030
Deltas: 1700000000, +10, +10, +10
Delta-of-delta: 1700000000, 10, 0, 0 ← almost free
Gorilla / XOR compression (from Facebook’s Gorilla paper) applies the same idea to float values: consecutive CPU readings of 45.2, 45.3, 45.1 share most of their bits, so XOR them and store only the differing bits.
📐 Result: 10–20× compression is routine, and it’s why 157 TB becomes ~10 TB. Combined with columnar layout, some systems report 90%+ reduction.
Automatic, declarative aging of data:
Raw (10 s resolution) → keep 7 days
1-minute averages → keep 30 days
1-hour averages → keep 1 year
1-day averages → keep forever
📐 This is the real storage win. You keep a decade of history for the cost of a few days of raw data, because nobody debugging an incident from 2023 needs 10-second granularity.
-- TimescaleDB
SELECT time_bucket('5 minutes', ts) AS bucket,
host,
avg(cpu) AS avg_cpu,
max(cpu) AS max_cpu
FROM metrics
WHERE ts > now() - interval '6 hours'
GROUP BY bucket, host
ORDER BY bucket;
Plus built-ins that are painful in plain SQL: gap-filling (what if a host reported nothing for a minute?), interpolation, last-observation-carried-forward, moving averages, and rate calculation.
| System | Character |
|---|---|
| TimescaleDB | A Postgres extension. Full SQL, joins with relational data, ACID. The easiest adoption if you’re already on Postgres — often the right answer. |
| Prometheus | Pull-based metrics + alerting. The de-facto standard for infrastructure monitoring. Local storage only; not a long-term store. |
| InfluxDB | Purpose-built TSDB. Its own query languages (InfluxQL/Flux). Strong for IoT and metrics. |
| VictoriaMetrics | Prometheus-compatible, much more efficient, scales horizontally. Increasingly the long-term-storage answer for Prometheus. |
| ClickHouse | Columnar OLAP that’s excellent at time series. Enormous scale, sub-second aggregations. |
| Cassandra with TWCS | Time-window compaction strategy makes an LSM store behave well for time series. Whole SSTables expire at once. |
| Amazon Timestream / Google Cloud Monitoring | Managed. Serverless, automatic tiering, no ops. |
🎙️ The pragmatic answer, often overlooked: “If we’re already running Postgres, I’d use TimescaleDB before adopting a separate system. We keep SQL, we can join metrics against our relational data, and we don’t add a datastore to operate. I’d move to something like ClickHouse or VictoriaMetrics if the volume genuinely exceeds what it handles.”
The universal model:
measurement: cpu_usage
tags: {host: "web-01", region: "eu-west", env: "prod"} ← indexed, low cardinality
fields: {value: 45.2, load_avg: 1.3} ← not indexed, the data
timestamp: 2026-07-22T10:00:00Z
Tags are indexed dimensions you filter and group by. Fields are the measurements.
Cardinality = the number of unique tag combinations. Every combination becomes a separate stored series.
10,000 hosts × 20 metrics × 5 regions = 1,000,000 series ✅ fine
Now someone adds user_id as a tag:
10,000 hosts × 20 metrics × 1,000,000 users = 2 × 10¹¹ series 💥
This is the most common way people destroy a time-series database, and it happens innocently —
someone adds a tag for request_id, session_id, email, or a full URL path. Memory usage
explodes, the index no longer fits, and the system falls over.
Rules:
🎙️ “I’d keep user_id out of the metric tags — that’s unbounded cardinality and it would blow up the
series count. If we need per-user analysis, that belongs in the event/log pipeline, queried in
ClickHouse.”
This distinction — metrics are for aggregates over bounded dimensions; logs and traces are for high-cardinality individual events — is a genuinely useful thing to articulate. → Three Pillars of Observability
Narrow (one row per metric): Wide (one row per timestamp):
ts, host, "cpu", 45.2 ts, host, cpu, memory, disk
ts, host, "memory", 78.1
Narrow is flexible (add metrics without schema change) but stores the timestamp and host repeatedly. Wide is more compact and faster for queries needing several metrics together, but requires a schema change to add a metric. Most systems use narrow; ClickHouse users often use wide for efficiency.
A genuine architectural fork, and Prometheus made the unusual choice:
Push (StatsD, InfluxDB, most agents) — the application sends metrics to the server. ✅ Works through firewalls and NAT; suits short-lived jobs and serverless. ❌ The server can be overwhelmed by a misbehaving client; you don’t know if a silent client is dead or just quiet.
Pull (Prometheus) — the server scrapes an HTTP endpoint on each target. ✅ The scrape itself is a health check — a failed scrape means the target is down, which is genuinely useful. The server controls its own load. Targets are discovered automatically via service discovery. ❌ Needs network reachability to every target; awkward for short-lived jobs (hence Prometheus’s Pushgateway) and for clients behind NAT.
🚨 “The scrape doubles as liveness detection” is the strongest argument for pull, and it’s a nice detail to bring up.
Time-series data appears more often than people notice:
| Domain | Data |
|---|---|
| Infrastructure monitoring | CPU, memory, request rate, error rate, latency histograms |
| Application metrics | Business KPIs, feature usage, conversion funnels |
| IoT | Sensor readings, device telemetry, GPS tracks |
| Finance | Tick data, OHLC candles, order book snapshots |
| Product analytics | Events, sessions, funnels |
| Real-time ML | Feature values over time, model prediction distributions |
🚨 The ride-hailing connection: driver location history is time-series data (device, time, lat/lng) even though current position lives in Redis. That split — current state in memory, history in a TSDB — is the standard pattern. → Geospatial Indexing
| Decision | Gain | Cost |
|---|---|---|
| TSDB over a general database | 10–20× compression, fast range queries, automatic retention | Another system; poor at non-temporal queries |
| TimescaleDB (Postgres extension) | Keep SQL, joins, ACID, one system | Lower ceiling than purpose-built at extreme scale |
| Aggressive downsampling | Keep years of history cheaply | Lose granularity — you can’t recover it later |
| High tag cardinality | Rich filtering | 💥 Memory blow-up; the classic failure |
| Pull model | Scrape = health check; server controls load | Needs reachability; awkward for ephemeral jobs |
| Push model | Works anywhere; suits short jobs | Server can be swamped; silent failures are invisible |
1. Compare storage directly. In Postgres, create a plain table and a TimescaleDB hypertable with the same schema. Insert 10 million rows of realistic metric data into each. Then:
SELECT pg_size_pretty(pg_total_relation_size('metrics_plain'));
SELECT pg_size_pretty(hypertable_size('metrics_hyper'));
Enable compression on the hypertable and measure again. The ratio is the argument.
2. Compare query speed. Run the same “average per 5-minute bucket over the last 6 hours” query against both. Then over the last 30 days. The gap widens dramatically with range size, because chunk exclusion means the hypertable never reads the irrelevant data.
3. Cause a cardinality explosion. In Prometheus or InfluxDB, emit a metric with a request_id
label. Generate 100,000 requests. Watch memory usage climb and query performance collapse. Then check
the series count. This is a five-minute exercise that will make you permanently careful about labels.
4. Set up retention. Configure a retention policy that drops chunks older than 7 days, and a continuous aggregate that rolls up to 1-minute averages. Watch the raw chunks disappear while the aggregates persist.