The language of time

The Elasticsearch essays · July 2026 · updated September 2026

The metrics engine rebuilt how time series are stored. This essay is about how they are asked: why ordinary aggregation gives confidently wrong answers on metrics, the TS command that fixes the order of operations, and the moment Elasticsearch started speaking PromQL, the native language of the world's dashboards, against its own columns.

1 · The confidently wrong answer

Here is a question that looks trivial: how many requests per second is my service handling? Three hosts export request_count, a counter, the total since each process started. Run the obvious query and watch it lie:

The failure isn't a bug; it's a grammar error. A counter's value is cumulative, so its meaning lives in the differences within one series, and FROM … | STATS knows nothing of series: it sees a bag of rows and adds them. Any aggregation that crosses series boundaries before doing per-series math produces a number, promptly, with no error message, that means nothing.

2 · TS: series first, world second

The TS command changes the order of operations. Under it, every STATS runs in two phases: an inner function collapses each individual time series within each time bucket (this is where RATE, AVG_OVER_TIME, LAST_OVER_TIME live), and only then does an outer function aggregate across series. Compose a query and watch the phases work:

inner · per series, per bucket
outer · across series
Three hosts, faint; the bold line is your answer. SUM(RATE(…)) is fleet throughput; MAX(MAX_OVER_TIME(…)) is worst-case anywhere; AVG(LAST_OVER_TIME(…)) is what TS quietly does when you write a bare AVG(cpu), because every series should count once per bucket, not once per sample. Note the discipline the engine enforces: RATE demands a field mapped as a counter and rejects gauges: the type system knows the grammar of time even when the query author doesn't.

3 · The window, and the blank-chart bug

Every one of those inner functions works over a window of samples, and the window is where real dashboards quietly break. Metrics arrive at a fixed cadence, say every 30 seconds. Now ask for 10-second buckets:

–
buckets with data
–
chart status
Dots are the samples, arriving every 30 seconds; columns are your buckets. A rate needs two samples to subtract, and a 30-second cadence can never put two samples inside a 10-second bucket, so every single bucket is null: the chart isn't broken, it's starved, and this is the classic "why is my graph blank" ticket. Two fixes, both on the sliders: widen the buckets to at least twice the scrape interval, or better, give RATE an explicit lookback window, RATE(request_count, 5m), the exact counterpart of PromQL's rate(x[5m]), which lets every evaluation reach back for enough samples no matter how fine the buckets.

4 · Windows that stream, rates that parallelize

Section 3 settled what a window means. This section is about what one costs, because a dashboard never asks for a single window: it asks for a five-minute window recomputed every thirty seconds, thousands of overlapping positions per panel. The naive plan re-reads every sample inside every position, so each point is read W / s times, once for every window that covers it. The engine's plan is different: fold each arriving sample into a per-bucket partial state, then answer any window position by merging the partials it spans. Each point contributes to partial state exactly once; the window slides forward without ever replaying history, and the smooth trend line comes out at roughly the cost of one pass. Scrub the window and watch the two plans pay:

–
point reads · re-read plan
–
point reads · streaming
–
cheaper · the overlap factor W / s
Drag the shaded window across the axis, or use the position slider. Re-read plan: every sample under the window is read again at every position that covers it; points glow hotter the more often they have been re-read, and the counter multiplies it out. Streaming plan: each step-wide bucket folds its samples into one partial chip, computed once, and a window is just the merge of the chips under the bracket. Honest simplification: a real partial carries whatever state its function needs (a sum and a count for an average, first and last samples for a rate); the chips here are stand-ins.

The other half of the execution story is what happens across cores. RATE is embarrassingly parallel across series and strictly ordered within one: shuffle the points inside a single counter and its resets become invisible. So the engine partitions the work by slicing the ordered _tsid range by prefix: each thread takes a contiguous run of whole series, and no series is ever split into a scrambled bag of points.

thread 1 · _tsid 00…4f
00a3… host-1 · cpu04c1… host-1 · req3f28… host-2 · cpu
thread 2 · _tsid 50…9f
51b2… host-2 · req5c99… host-3 · cpu8d04… host-3 · req
thread 3 · _tsid a0…ff
a0f4… host-4 · cpuc31e… host-4 · reqe77d… host-5 · cpu
Slicing the ordered _tsid range by prefix: every series lands whole, in order, on exactly one thread.

Prefix partitioning plus bucket interpolation at the boundaries preserve exactly the two things a counter's meaning lives in, the resets and the boundary deltas, while making fewer passes over the data. (What a reset is, and why it must be absorbed rather than summed, is the metrics engine.) Section 3 was the semantics; this is the economics: the window means what it always meant, the engine just stopped paying for it per position.

5 · PromQL, spoken natively

Then there is the question of the dashboards that already exist. They speak PromQL, and rewriting them is the migration cost nobody pays. So Elasticsearch learned the language instead, natively, against the same columns. The same question, both tongues:

The door swings both ways. A Prometheus-compatible HTTP API lives at /_prometheus/api/v1/…: remote write in, instant and range queries out, plus metric metadata. Grafana configures Elasticsearch as an ordinary Prometheus data source, and existing dashboards render. And inside ES|QL, the PROMQL source command embeds a PromQL expression as the head of a pipe, so its result can flow on through WHERE, LOOKUP JOIN, anything: two grammars, one query. Honestly labeled: both arrived as a technical preview in 9.4 and went GA in 9.5, where more than 80% of a corpus of real-world PromQL queries runs unmodified. The gaps are known and listed: vector matching with on()/group_left, set operators like and/unless, topk, and a handful of functions. How that engine work was done is the clock problem.

One storage engine underneath, three ways in: TS when you think in pipes, PROMQL when the expression already exists, the HTTP API when the client was built for Prometheus. The stored bytes never care which language asked. How those bytes got so small is the metrics engine; the machinery every query rides is ES|QL: one language for the whole machine.