Time series and analytics: partition, roll up, scan columns
Metrics and events arrive in time order and are read in time windows. Partition by time, pre-aggregate what dashboards read, and move heavy scans to a column store.
- 1OLTP reads and writes a few rows by key. OLAP scans one or two columns of millions of rows.
- 2A column store reads only the columns a query names. Similar values side by side compress well.
- 3Partition time series by day. Recent data is hot; drop old days whole.
- 4Dashboards read rollups that store count and sum, not raw rows.
- OLTP: many short transactions, a few rows each, by key. Store rows.
- OLAP: few large queries, millions of rows, few columns. Store columns.
- Time series: append mostly, read by time window, recent data most.
A row store reads every column of the rows it scans. A column store reads only the columns the query names, and those compress well.
| method | where | status |
|---|---|---|
| Scan raw rows on the OLTP primary | Postgres | Not approved |
| Raw rows, partitioned by day | Postgres | Short windows |
| Rollup tables, upserted by a job | Postgres | Approved |
| Materialised view, full refresh | Postgres | Short history |
| Continuous aggregates | TimescaleDB | Approved |
| Column store, fed by CDC | ClickHouse | Approved |
| Host metrics with bounded labels | Prometheus | Approved |
| A 1% sample of pages | Postgres | Totals, averages |
A full refresh recomputes every bucket of history. A rollup job writes only the last window.
I keep raw samples partitioned by day, and the dashboard reads rollups. A column store comes in when ad hoc scans outgrow Postgres.
- rows
- 50 hosts × 1 sample per 10 s × 7.5 days = 3,240,000
- row size
- 52.3 bytes per row in the heap, for 20 bytes of data
- index
- (host, ts) on each partition: 98.3 MB beside 161.5 MB of rows
CREATE TABLE metrics (
ts timestamptz NOT NULL,
host int NOT NULL,
value double precision NOT NULL
) PARTITION BY RANGE (ts)1;
CREATE INDEX ON metrics (host, ts)2;
CREATE TABLE metrics_1m (
minute timestamptz NOT NULL,
host int NOT NULL,
n bigint NOT NULL,
total double precision NOT NULL4,
lo double precision NOT NULL,
hi double precision NOT NULL,
PRIMARY KEY (minute, host)3
);
CREATE TABLE metrics_1h (
hour timestamptz NOT NULL,
host int NOT NULL,
n bigint NOT NULL,
total double precision NOT NULL,
lo double precision NOT NULL,
hi double precision NOT NULL,
PRIMARY KEY (hour, host)
);
CREATE FUNCTION add_day_partition5(day date) RETURNS void LANGUAGE plpgsql AS $$
BEGIN
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF metrics FOR VALUES FROM (%L) TO (%L)',
'metrics_' || to_char(day, 'YYYYMMDD'),
day::timestamp AT TIME ZONE 'UTC',
(day + 1)::timestamp AT TIME ZONE 'UTC');
END $$;- 1One child table per day. A filter on ts skips the other days, and retention drops a day whole.
- 2Created on the parent, so every partition gets its own copy. One host over a day is an index range scan.
- 3One row per host per minute. The rollup job upserts on this key, so a rerun replaces the row.
- 4Store the sum and the count, not the average. Sums add up across buckets; averages do not.
- 5A daily job creates tomorrow before any sample for it arrives. A write with no partition fails.
Raw samples go in one partition per day. Rollups keep count, sum, min and max per minute and per hour, so they combine.
Step 1: Write
- The service writes orders in short transactions.
- Each one touches a few rows by key.
- No dashboard query runs here.
If it fails
Nothing new: the primary keeps its own durability. Analytics never slows a write.
I keep the OLTP primary for transactions. Changes flow from its WAL through a log into a column store, and analytics runs there.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Postgres | Declarative partitioning: PARTITION BY RANGE | One table per day, behind one table name. | Multi-tenant splits, archives |
| Postgres | Partition pruning | A filter on ts reads 2 of 9 partitions for the last 24 h. | Any range or list partition key |
| Postgres | DROP or DETACH a partition | Retention in 0.79 ms, with no row scan and no dead rows. | Archiving to cheap storage |
| Postgres | date_bin(interval, ts, origin) | Fixed buckets from a UTC origin, whatever the session time zone. | Any time-bucketed report |
| Postgres | INSERT ... ON CONFLICT DO UPDATE | The rollup job replaces a bucket, so a rerun or a late sample is safe. | Idempotent writes, counters |
| Postgres | Materialised view, REFRESH CONCURRENTLY | A stored query result that reads keep using during a refresh. It needs a unique index. | Expensive reports |
| Postgres | TABLESAMPLE SYSTEM (1) | Reads 1% of pages for an approximate total or average. | Data exploration |
| Postgres | Logical decoding, replication slots | A CDC reader gets every committed change from the WAL. | Search indexing, cache invalidation |
| Postgres | Row storage | Limit A scan reads every column; 52.3 bytes per sample here. | |
| TimescaleDB | Hypertables, continuous aggregates, columnar compression | Day partitions, rollups and compression as one Postgres extension. | IoT, finance ticks |
| ClickHouse | MergeTree: sorted column parts, codecs (Delta, DoubleDelta, Gorilla), TTL | Compressed columns, sorted by the key a filter uses, with old rows expired. | Logs, product analytics |
| ClickHouse | Materialised views that run on insert | Rollups maintained per batch, with no refresh. | Real-time counters |
| BigQuery | Serverless columns, billed by bytes scanned; partitioning and clustering | Ad hoc SQL over all history. A partition filter cuts the bytes billed. | Warehouse, ML features |
| BigQuery | APPROX_COUNT_DISTINCT (HyperLogLog++) | Distinct users in a fixed small memory, with a small error. | Unique visitors |
| Prometheus | Pull scraping, 2-hour blocks, recording rules, 15-day default retention | Host and service metrics at 1 to 2 bytes per sample. | Alerting on SLOs |
Tool facts from each project's documentation: PostgreSQL, TimescaleDB, ClickHouse, BigQuery and Prometheus.
Postgres gives me partitions, pruning and upserts for rollups. A column store gives me compressed columns and aggregates on insert. Prometheus is for bounded host metrics.
every minute, for [now - 5 min, now): // re-read a little, for late samples1
group raw samples by (minute, host)
upsert into the minute rollup: count, sum, min, max // replace the row, never add to it2
every hour, for the last hour:
group minute rows by (hour, host) into the hour rollup
dashboard: average = sum(total) / sum(n) // never an average of averages3
every day:
create the partition for tomorrow // a write never finds no partition
drop the raw partition older than 30 days // one file removed, no row scan4- 1Samples can arrive minutes late. Each run covers a few closed minutes again.
- 2The upsert writes the full count for the bucket. A rerun gives the same row, so retries are safe.
- 3Divide the summed totals by the summed counts. Buckets with more samples weigh more.
- 40.79 ms in the lab, against 454 ms to DELETE the same day row by row.
Tested source SQL: minute and hour rollups · SQL: partitions · SQL: dashboard on the rollup
INSERT INTO metrics_1m (minute, host, n, total, lo, hi)
SELECT date_bin('1 minute', ts, '2000-01-01 00:00+00'), host, count(*), sum(value), min(value), max(value)
FROM metrics
WHERE ts >= $1 AND ts < $2
GROUP BY 1, 2
ON CONFLICT (minute, host) DO UPDATE
SET n = EXCLUDED.n, total = EXCLUDED.total, lo = EXCLUDED.lo, hi = EXCLUDED.hi;
INSERT INTO metrics_1h (hour, host, n, total, lo, hi)
SELECT date_bin('1 hour', minute, '2000-01-01 00:00+00'), host, sum(n), sum(total), min(lo), max(hi)
FROM metrics_1m
WHERE minute >= $1 AND minute < $2
GROUP BY 1, 2
ON CONFLICT (hour, host) DO UPDATE
SET n = EXCLUDED.n, total = EXCLUDED.total, lo = EXCLUDED.lo, hi = EXCLUDED.hi;CREATE FUNCTION add_day_partition(day date) RETURNS void LANGUAGE plpgsql AS $$
BEGIN
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF metrics FOR VALUES FROM (%L) TO (%L)',
'metrics_' || to_char(day, 'YYYYMMDD'),
day::timestamp AT TIME ZONE 'UTC',
(day + 1)::timestamp AT TIME ZONE 'UTC');
END $$;
CREATE FUNCTION drop_day_partition(day date) RETURNS void LANGUAGE plpgsql AS $$
BEGIN
EXECUTE format('DROP TABLE IF EXISTS %I', 'metrics_' || to_char(day, 'YYYYMMDD'));
END $$;SELECT minute AS bucket, sum(n) AS n, sum(total) AS total, min(lo) AS lo, max(hi) AS hi
FROM metrics_1m
WHERE minute >= $1 AND minute < $2
GROUP BY 1 ORDER BY 1;A job upserts count, sum, min and max per minute, then per hour. A daily job creates tomorrow's partition and drops the oldest.
- median time
- 147 ms
- rows read
- 648,000
- pages read
- 32.3 MB
- partitions
- 2 of 9
- answer
- 1,440 points, equal to the raw rows
Postgres 16, one process per query, warm cache. Median of 5 runs. The view refresh takes 5,692 ms; rolling up one hour takes 56 ms.
On the minute rollup the 24-hour dashboard reads 9 times fewer rows. The hour rollup reads 360 times fewer for a week.
- The planner compares the filter with each partition's bounds and skips the rest.
- A prepared statement with a generic plan still prunes, when execution starts.
- Keep the count to a few thousand at most. Planning time and memory grow with it.
I filter on the partition column itself. A function around it hides the bounds, and the planner scans every partition.
| 7-day average | rows read | time | answer |
|---|---|---|---|
| Exact, every row | 3,024,000 | 491 ms | 54.9983 |
| TABLESAMPLE SYSTEM (1) | 29,673 | 5.8 ms | 55.0463 |
| question | approximate tool | status |
|---|---|---|
| Total, average | Sample of pages or rows | Approved |
| Distinct count | HyperLogLog sketch | Approved |
| p99 latency | Quantile sketch or histogram | Approved |
| Per-bucket counts on a sample | Sample of pages | Large buckets |
| Money, billing | Any approximation | Not approved |
The sample was off by 0.087%. SYSTEM picks whole pages, so rows that arrived together are sampled together.
For a total or an average I can read a sample. For distinct counts I use a HyperLogLog sketch.
- rows
- 20 bytes a sample: 8.64 MB for the day
- columns
- time + host + cpu, each with its best scheme: 0.811 MB, 10.7× smaller
- cited
- Prometheus stores 1 to 2 bytes per sample. The Gorilla paper (VLDB 2015) reports 1.37 bytes per point.
encode(column, scheme):
v = column
IF delta: v = each value minus the one before1
IF delta-of-delta: v = delta applied twice2
IF runs: write (value, count) per run of equal values3
ELSE: write each value as a varint4 // 1 byte: -64..63
// every 10 s: 1000, 1010, 1020 ... -> 1000, 10, 10 ...
// as runs: (1000, 1) (10, 8639)5- 1Counters and timestamps turn into small numbers. Small numbers take 1 or 2 bytes as varints.
- 2A regular interval becomes zeros. Jitter makes small numbers around zero.
- 3Long runs (a host id, an up flag, a fixed interval) collapse to one pair each.
- 4Zigzag varint: 7 bits per byte, sign folded into the low bit.
- 5A day of timestamps for one host is two pairs. For 50 hosts, 355 bytes in all.
Tested source Go: encode a column
// deltas returns the first value, then each value minus the one before it.
func deltas(xs []int64) []int64 {
out := make([]int64, len(xs))
for i, x := range xs {
if i == 0 {
out[i] = x
} else {
out[i] = x - xs[i-1]
}
}
return out
}
// Encode writes a column with one scheme: a count, then the values. A regular timestamp column
// becomes all zeros after delta-of-delta, and run-length encoding stores those zeros as one pair.
func Encode(xs []int64, s Scheme) ([]byte, error) {
out := binary.AppendUvarint(nil, uint64(len(xs)))
vals := xs
switch s {
case Delta, DeltaRLE:
vals = deltas(xs)
case DeltaOfDelta, DeltaOfDeltaRL:
vals = deltas(deltas(xs))
}
switch s {
case Plain:
for _, v := range vals {
out = binary.LittleEndian.AppendUint64(out, uint64(v))
}
case Varint, Delta, DeltaOfDelta:
for _, v := range vals {
out = binary.AppendVarint(out, v) // zigzag: small negatives stay small
}
case RLE, DeltaRLE, DeltaOfDeltaRL:
for i := 0; i < len(vals); {
run := 1
for i+run < len(vals) && vals[i+run] == vals[i] {
run++
}
out = binary.AppendVarint(out, vals[i])
out = binary.AppendUvarint(out, uint64(run))
i += run
}
default:
return nil, fmt.Errorf("unknown scheme %q", s)
}
return out, nil
}
- Sort the data by the columns you filter on: tenant or host, then time. Runs get long and values close.
- Pick an encoding per column, then add a general compressor such as LZ4 or ZSTD on top.
- A jittered timestamp has no long runs: plain delta wins at about 1 byte a value.
Sorted by host and time, a timestamp column is a few runs, and a gauge needs about 1.9 bytes a value. The three columns take 10.7 times less than rows.
| event | result | how to stay safe | saved by |
|---|---|---|---|
| A label holds user ids or request ids (high cardinality) | One series per value. Memory and the index grow until the metrics server fails. | Keep labels to bounded sets: host, route, status. Send per-user data to logs or the warehouse. | Bounded labels |
| No retention policy | Raw data grows every day. Queries and backups slow down, disks fill. | Drop raw partitions after N days. Keep rollups longer. | DROP partition |
| Tomorrow's partition was not created | Writes after midnight fail: no partition for the row. | Create partitions days ahead. Alert when fewer than 2 are ahead. | Daily job |
| A sample arrives after its minute was rolled up | The rollup misses it. | Re-read a trailing window each run. The upsert replaces the row. | ON CONFLICT |
| A filter wraps ts in a function | Every partition is scanned: 9 of 9. | Filter on ts directly. Check the plan. | Pruning |
| Dashboards scan the OLTP primary | Large scans compete for CPU and cache with checkout. | Read rollups, a replica, or the warehouse. | CDC |
| The CDC reader stops | The replication slot keeps WAL on the primary, and its disk fills. | Alert on slot lag. Cap kept WAL with max_slot_wal_keep_size. | Slot limit |
| A chart averages minute averages | Wrong when minutes hold different counts. | Store sum and count. Divide at the end. | Rollup schema |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One table with an index on (host, ts). | Recent windows for one host read by index. Millions of rows. | Retention deletes rows one by one: 454 ms for one day here, plus dead rows for vacuum. |
| 2 | Day partitions and a daily job. | Retention by DROP. Windows read only their days: 147 ms for 24 h here. | A week-long chart reads every raw row: 922 ms here. |
| 3 | Minute and hour rollups, upserted. | Week charts in 2.8 ms from the hour rollup. Rollups keep years. | Many rollups and compression to maintain by hand. |
| 4 | TimescaleDB on the same Postgres. | Automatic partitions, continuous aggregates, column compression. | Billions of rows, ad hoc scans over many columns, data from many sources. |
| 5 | CDC into a column store. | Ad hoc SQL over all history, away from the OLTP primary. | Top of the ladder. Scale the warehouse itself. |
Steps 1 to 3 are measured in the lab. Do not start at step 5: a warehouse adds a pipeline to run, and dashboards then lag the primary by seconds or minutes.
I start with one Postgres table and a time index. I add partitions, then rollups. A column store comes when ad hoc scans over all history matter.
0 of 9 known
Why does a column store read less to compute avg(cpu)?
Why does a column of regular timestamps shrink to almost nothing?
Why does the rollup store count and sum, not the average?
A dashboard filters on date_bin('1 hour', ts) and scans all 9 partitions. Why?
How do you delete samples older than 30 days?
When is a materialised view enough, and when do you need a rollup table?
A sample arrives 3 minutes late, after its minute was rolled up. What happens?
A team adds user_id as a Prometheus label. What breaks?
Can an approximate answer be good enough?
- row
- 52.3 bytes per sample in a Postgres heap, for 20 bytes of data.
- columns
- 10.7× smaller than rows with delta and run-length encoding.
- rollup
- 9× fewer rows per minute bucket; 360× fewer per hour for a week.
- week
- 922 ms raw, 2.8 ms from the hour rollup.
- retention
- Drop a day: 0.79 ms. Delete it: 454 ms.
- sample
- 1% of pages: 0.087% off, in 5.8 ms.
- Prometheus
- 1 to 2 bytes per sample; 15 days kept by default.
Postgres 16 on an 8-core laptop, one process per query, no parallel workers. Use these as orders of magnitude.