swecrets

Metrics Tables and Time-Series Patterns

Application — Rung 318 min read

Learning Objectives

  • Design a narrow (long) metrics table schema — metric name, tags, timestamp, value — and explain why it scales better than a wide, one-column-per-metric table
  • Choose an ORDER BY for a metrics table that matches its dominant query pattern, applying the prefix-narrowing rule to a time-series workload
  • Select compression codecs suited to monotonically-increasing timestamps and slowly-changing metric values
  • Design a retention strategy for a metrics table using TTL and a materialized-view rollup, and explain the trade-off between raw-resolution retention and storage cost

Why This Matters

Monitoring and observability — CPU, memory, request latency, queue depth, reported by thousands of hosts every few seconds — is one of the most common real-world reasons a team stands up ClickHouse in the first place. It's also one of the easiest schemas to get wrong in a way that doesn't show up on day one. A metrics table designed around "whatever columns seemed obvious" works fine with ten metrics and a handful of hosts; it turns into a wall of ALTER TABLE statements and a slow dashboard by the time it has hundreds of metrics and thousands of hosts. Module 3 gave you the mechanics — MergeTree, parts, the ORDER BY prefix rule. This lesson is about spending that mechanical understanding on an actual design decision you'll face: what does a metrics table look like, and why.

What makes metrics data different from a generic events table

A metrics workload has a specific shape: a numeric value, sampled at (usually) regular intervals, identified by a metric name and a set of dimensions — which host, which region, which environment. cpu.load on host-042 in us-east at 14:32:10 is 0.73. Compare that to a generic event — a page view, a click, an API request — which tends to carry a variable, ad-hoc bag of fields specific to that event type. Metrics are far more uniform: the same four-part shape (name, tags, timestamp, value) repeats for every single row, no matter which metric it is. That uniformity is what makes a single, general-purpose table structure realistic for metrics in a way it usually isn't for arbitrary events.

The narrow (long) table pattern

The idiomatic shape stores one row per (metric, tag set, timestamp) combination, with a single value column — often called the "narrow" or "long" schema, borrowing the terms from the same wide-vs-long distinction in general tabular data modeling:

Narrow schema — one row per samplesql
CREATE TABLE metrics
(
    metric_name String,
    host        LowCardinality(String),
    region      LowCardinality(String),
    tags        Map(LowCardinality(String), String),
    ts          DateTime64(3),
    value       Float64
)
ENGINE = MergeTree
ORDER BY (metric_name, host, ts);

Every metric — cpu.load, mem.used, http.requests.count, a metric nobody has invented yet — fits into the same four columns. Adding a new metric is just a new value of metric_name showing up in an INSERT; it never requires a schema change. host and region are pulled out as dedicated columns because they're the dimensions most queries filter on; everything else that varies by deployment — a Kubernetes pod name, a customer ID, a build version — goes into tags, a Map column that can hold an open-ended, per-row set of key/value pairs without any ALTER TABLE. Both host and region are also wrapped in LowCardinality — a modifier telling ClickHouse to store the column as a dictionary of distinct values plus a compact per-row index into it, which pays off exactly when a column repeats a small set of values across millions of rows, as a hostname or region name does.

Choosing ORDER BY for a metrics table

This is the direct payoff from the previous module's prefix rule, applied to a real decision: which column goes first in ORDER BY depends entirely on what your queries filter on first. The overwhelmingly dominant metrics query pattern is "show me this metric, for this host (or this small set of hosts), over some time range" — a dashboard panel, an alert evaluation, a single service's latency graph. That means metric_name should almost always lead: every one of those queries pins it down with an equality condition, so it's the column that benefits every single time. host comes next because it's the dimension queries narrow by second most often, and ts comes last — the column every query has a range on, never an equality, which is exactly where a trailing key column belongs.

region, notice, isn't in the key at all here — a query that filters by region without metric_name and host won't get any dependable narrowing from this table's primary index, for exactly the reason the previous lesson covered: it's not a leading, contiguous prefix match. That's a real limitation of this schema, not an oversight, and the design exercise later in this lesson asks you to reason about what to do when a query pattern like that actually matters.

Codecs built for time-series shapes

Two columns in a metrics table have unusually exploitable structure, and ClickHouse ships codecs designed specifically for it. Timestamps within an ORDER BY-sorted part are monotonically increasing, and consecutive samples are usually close together — so the difference between consecutive values is small and repetitive, which is exactly what delta-style codecs compress well:

Time-series-tuned codecssql
CREATE TABLE metrics
(
    metric_name String,
    host        LowCardinality(String),
    region      LowCardinality(String),
    tags        Map(LowCardinality(String), String),
    ts          DateTime64(3) CODEC(DoubleDelta, ZSTD),
    value       Float64 CODEC(Gorilla, ZSTD)
)
ENGINE = MergeTree
ORDER BY (metric_name, host, ts)
TTL toDateTime(ts) + INTERVAL 14 DAY;

DoubleDelta encodes the change in the change between consecutive values — built for exactly the near-constant-interval timestamps a metrics pipeline produces. Gorilla is a floating-point codec taken from Facebook's Gorilla time-series storage paper, built for metric values that tend to stay close to their previous reading rather than jumping around. Both are then handed to a general-purpose codec (ZSTD here) for a second pass — the specialized codec exploits the sequence's structure first, and general compression mops up whatever's left.

Real-world case study: an observability platform's schema on ClickHouse

SigNoz, an open-source observability platform (metrics, traces, and logs) built directly on ClickHouse, uses this narrow-schema pattern for its metrics store: a samples_v4 table keyed by a time series identifier and timestamp, with a separate time_series_v4 table mapping each time series identifier to its metric name and label set — plus pre-aggregated samples_v4_agg_5m and samples_v4_agg_30m tables for exactly the kind of downsampled rollup this lesson's design exercise builds. The split — one table for "what is this time series" metadata, one table for the actual timestamped samples — is the same narrow-table instinct this lesson builds, just with the tag lookup normalized into its own table rather than inlined as a Map on every row; it's a variation on the same idea, not a different one, and the trade-off between inlining tags and normalizing them into a separate table is exactly the kind of decision Module 4's denormalization-tradeoffs lesson develops further.

Idiom vs. anti-pattern

Idiomatic — narrow schema, one table for every metricsql
CREATE TABLE metrics
(
    metric_name String,
    host        LowCardinality(String),
    ts          DateTime64(3) CODEC(DoubleDelta, ZSTD),
    value       Float64 CODEC(Gorilla, ZSTD)
)
ENGINE = MergeTree
ORDER BY (metric_name, host, ts);
-- Adding a new metric: just start inserting rows with a new metric_name.
Anti-pattern — wide schema, one column per metricsql
CREATE TABLE host_metrics_wide
(
    ts          DateTime,
    host        String,
    cpu_load    Float64,
    mem_used    Float64,
    disk_used   Float64,
    net_in      Float64,
    net_out     Float64
    -- every new metric means an ALTER TABLE ADD COLUMN on a table
    -- that's already carrying months of history
)
ENGINE = MergeTree
ORDER BY (host, ts);

The wide table isn't a strawman — it's the schema almost everyone reaches for first, because it looks exactly like a spreadsheet of "host, time, and its readings," and it works fine at small scale. It falls apart for reasons that only show up as the system grows: every team that wants to track a new metric needs a schema migration on a table other teams also depend on; a row for a host that's only reporting cpu_load this cycle still has to represent (or null out) every other metric's column; and a query for "every metric this host reported" has to know every column name in advance instead of just filtering on metric_name. The narrow schema trades a slightly less obvious row shape for a table that never needs to change shape again.

Design exercise

A monitoring team runs roughly 2,000 hosts, each emitting about 150 distinct metrics every 10 seconds, tagged with host, region, and environment (prod or staging). Three query patterns matter:

  1. Dashboards — "show cpu.load for host-0423 over the last 6 hours." Single metric, single host, short range. This is the large majority of query volume.
  2. Alerting — "scan the last 2 minutes of every metric, every host, for threshold breaches." Broad in metric and host, narrow in time.
  3. Capacity planning — "show p95 of mem.used across all hosts in region = us-east, over the last 30 days, downsampled to 5-minute buckets." Broad in host, narrow in metric, filtered by a dimension that isn't in the table's key, over a long range.

Reasoning Prompt

Design the table (or tables) for this system: the schema, the ORDER BY, and a retention strategy. Write out your reasoning for each of the three query patterns above — does your design serve all three well, and if not, which one(s) do you deliberately accept a worse fit for, and why? Write your answer before revealing the model answer below.

Model Answer

A raw metrics table, narrow schema, ORDER BY (metric_name, host, ts), much like this lesson's running example — plus a second, separate rollup table fed by a materialized view. Two tables, not one, because no single ORDER BY serves all three query patterns well, and pretending otherwise means picking a schema that's secretly optimized for only one of them.

  • Dashboards are served directly and well by the raw table: metric_name and host both get equality filters, so the primary index narrows straight to the matching key range, and the 6-hour window narrows further within it. This is the pattern to optimize the primary table for, since it's the large majority of query volume.
  • Alerting doesn't get any help from the primary index — it wants every metric, every host — but it's bounded to the last 2 minutes, which is a tiny slice of any reasonably recent MergeTree part. The acceptable move here is to not fight this in the schema at all: accept that this query does a broad scan, and rely on it being a broad scan over a small, recent amount of data rather than trying to add a key column that would only serve this one pattern at the expense of the dashboard case.
  • Capacity planning is the pattern the raw table genuinely can't serve well: it filters by region, which isn't in ORDER BY at all, over a 30-day range that's far more data than the raw table needs to keep at full resolution anyway. Rather than distorting the primary key to accommodate an infrequent query, the better move is a materialized view that triggers on every insert into the raw table, pre-aggregates into 5-minute buckets, and writes into a second table keyed ORDER BY (region, metric_name, ts_bucket) — a key chosen specifically for this query pattern, on a table that's already orders of magnitude smaller because it's downsampled.
  • Retention follows the same split: the raw table only needs to serve dashboards and alerting, both short-range, so a TTL of one to two weeks is reasonable and keeps the largest, highest-cardinality table small. The rollup table serves a 30-day-and-beyond use case at a fraction of the row count, so it can afford a much longer TTL — a year or more — without approaching the raw table's storage footprint.

This isn't the only defensible design. A team whose dashboards are overwhelmingly per-host across many metrics, rather than per-metric across many hosts, could reasonably choose ORDER BY (host, metric_name, ts) instead — the same reasoning, applied to a different dominant query pattern. What should transfer regardless of that choice is the shape of the reasoning: identify the query pattern with the highest volume or tightest latency requirement, key the primary table for that one, and push every pattern the primary key can't serve into a purpose-built rollup rather than distorting the main table's key to chase all three at once.

Summary

A metrics table is best modeled narrow: one row per (metric, tags, timestamp, value), which lets new metrics show up as new data rather than new columns — the opposite of a wide table with one column per metric, which requires a schema migration for every new thing worth measuring. ORDER BY should lead with the columns your dominant query pattern filters by equality on (typically metric_name, then a high-value dimension like host), with the time column last, since it's almost always filtered by range rather than equality. Timestamps and metric values both have exploitable structure that general-purpose compression misses — DoubleDelta for near-regular-interval timestamps, Gorilla for slowly-changing floating-point values — layered under a general codec like ZSTD. No single ORDER BY serves every query pattern a metrics system needs; the idiomatic response to a pattern your primary key can't serve well is a materialized-view rollup into a second table keyed for that pattern, not a compromise key that serves everything a little worse. Retention (TTL) should scale with how coarse the data already is — short for high-resolution raw data, much longer for downsampled rollups.

Quiz

  1. 1. A metrics table stores dozens of different measurements. Why is a narrow schema (metric_name, tags, ts, value) generally preferred over a wide schema (one column per metric)?

  2. 2. A metrics table is ORDER BY (metric_name, host, ts). The team's dashboards almost always query one metric for one host over a time range. A new requirement asks for 'total requests across ALL hosts, for a given environment, over the last month.' What's the most idiomatic response?

  3. 3. Why is DoubleDelta a well-suited codec for a metrics table's timestamp column, specifically?

  4. 4. A team is deciding whether a given dimension (say, a Kubernetes pod name) should be its own dedicated column or a key inside a metrics table's tags Map. What should drive that decision?

  5. 5. Why does the design exercise's model answer recommend a shorter TTL on the raw metrics table than on its rollup table?