Skip to main content

Documents ZigTSDB v1.5.0 · applies to the 1.5.x line

ZQL Reference

ZQL is Anzaran's SQL-flavoured query language for time series. Queries are submitted URL-encoded to GET /query?zql=... (decoded size ≤ 2048 bytes) or via zigtsdb-cli query. Keywords are case-insensitive.

Statement Forms

SELECT <select-list>
  FROM <metric> [AS <alias>]
  [JOIN <metric> [AS <alias>] [ON time] [TOLERANCE <n> <unit>]]
  [WHERE <condition>]
  [GROUP BY <group-term> [, <group-term>]... [FILL(<policy>)]]
  [ORDER BY value|time [ASC|DESC]]
  [TOPK <n> | BOTTOMK <n>]
  [LIMIT <n> [OFFSET <m>]]

SHOW METRICS
SHOW SERIES
SHOW LABEL VALUES <key>

Durations anywhere in ZQL accept the units ms, s/sec/second(s), m/min/minute(s), h/hour(s), d/day(s); a bare number is nanoseconds.

A <group-term> is a label key, __metric__, time_bucket(time, <duration>), or a calendar bucket hour|day|month|year(time [, '<zone>']).


SELECT List

Form Examples
Raw points SELECT *, SELECT DISTINCT *
Aggregation avg(value), sum(value), min(value), max(value), count(*), stddev(value), rate(value), increase(value), percentile(value, 95), last(value), first(value)
Window function moving_avg(value, 10), ewma(value, 50), deriv(value)
Forecast forecast(value, 100), forecast(value, 100, 'ridgecv')
Arithmetic avg(value) * 100, (max(value) - min(value)) / 2
Scalar function abs(sum(value)), clamp(avg(value), 0, 100)
Threshold avg(value) > 50
Multiple columns SELECT avg(value) AS mean, max(value) AS peak FROM cpu

Aggregate semantics

  • rate(value) — per-second rate (last − first) / Δseconds over the range (assumes a monotonic counter).
  • increase(value) — total rise, tolerating counter resets.
  • percentile(value, q) — exact percentile with q in [0, 100].
  • stddev(value) — population standard deviation.
  • last(value) / first(value) — the stored sample at the maximum / minimum timestamp in the range (exact, never an arithmetic result, never assuming sorted input). When several points share the extreme timestamp, last returns the largest of the tied values and first the smallest, so the answer does not depend on merge order. Under GROUP BY host / time_bucket(...) the same rule applies per group / per bucket.

NaN samples are skipped, as for count/min/max; an empty or all-NaN range yields no data.

Multi-series counters. When a metric matches more than one series, rate and increase are computed per series and then summed (PromQL sum(rate(...)) semantics) — never over the interleaved merged stream, where cross-series value drops would read as counter resets. A series with fewer than 2 in-window points contributes nothing; if no series contributes, the query returns no data. The same contract applies inside each GROUP BY <label> group. Exception: GROUP BY time_bucket(...) with rate/increase evaluates per bucket over the merged stream — treat multi-series bucketed rate as unspecified.

Window functions

moving_avg(value, n) (window size n), ewma(value, pct) (smoothing factor as a whole-number percentage: 50 means alpha 0.5; omitted or 0 means 0.3), and deriv(value) return a values series rather than a single scalar.

Forecast

forecast(value, steps [, 'linear' | 'ridgecv']) fits a model to the series in the query window and extrapolates steps points ahead, 1 ≤ steps ≤ 10000. A missing, zero, fractional or oversized horizon and an unknown algorithm name are parse errors (400). The fit is on time: irregular sampling fits its true slope, and the value emitted at last_ts + k·step is the model's value at that instant; non-finite samples are dropped first. The step is the mean observed spacing, or the bucket width under GROUP BY time_bucket(time, X) (a calendar or zoned bucket has none and is refused), which forecasts the per-bucket aggregate (default avg; FILL applies first).

  • linear — least squares; needs 2 finite points.
  • ridgecv — shrinks the slope by n/(n+α) with α chosen by rolling-origin cross-validation over {0.1, 1, 10, 100} (below 10 samples the lightest α is used); needs 3 finite points.

Fewer points, or a zero interval (duplicate timestamps), answers insufficient_data / zero_interval (400). Every series is forecast independently: GROUP BY <label> yields one forecast per label value, a metric matching several series with no label grouping yields one per series ("group_by":"__series__"), and exactly one series yields the flat shape {"status":"ok","forecast":[{"ts":…,"value":…},…],"predictions":[…]}. LIMIT/OFFSET index the group list, not points, and only the groups inside that window are fitted; the flat single-series shape ignores them.

Arithmetic and scalar functions

+ - * / over aggregates and numeric literals of a single metric, with ( ) grouping and unary minus. Scalar functions abs, ceil, floor, round, sqrt, and clamp(x, lo, hi) wrap any sub-expression. Comparison operators > < >= <= = != yield 1.0 (true) / 0.0 (false). Precedence, loosest → tightest: comparisons, + -, * /. Cross-metric arithmetic is not supported.

Multiple columns

A comma-separated list of aggregate/arithmetic expressions, each with an optional AS <name>. Returns {"status":"ok","columns":{"mean":20.0,"peak":30.0}}. Columns must be aggregates or expressions (not bare fields); multiple columns cannot be combined with GROUP BY.

Field qualifiers

alias.field (e.g. avg(a.value)) resolves the alias and uses the field. Malformed numeric literals (1.2.3) are rejected at lex time.


WHERE

-- Label equality / inequality (inverted-index lookups)
WHERE host = 'server1'
WHERE host != 'db-1'

-- Regex label matching: =~ matches, !~ negates (anchored, full-match)
WHERE host =~ 'web-.*'
WHERE host !~ 'db-[0-9]+'

-- Absolute time bounds (Unix nanoseconds)
WHERE time >= 1704067200000000000 AND time < 1704153600000000000

-- Relative time: now() with a duration offset
WHERE time > now() - 5m
WHERE time > now() - 1h AND time < now()

-- Value predicates on points
WHERE value > 100
WHERE value >= 10 AND value <= 90

-- Boolean logic: AND binds tighter than OR; NOT negates; ( ) group
WHERE (host = 'web' OR host = 'db') AND env = 'prod'
WHERE NOT host = 'db'

Conditions form a real boolean tree. Label predicates resolve by series-set algebra over the metric's series (AND = intersect, OR = union, NOT = difference); value predicates filter points. Mixing a label predicate and a value predicate under OR is best-effort — the merged point stream loses per-series identity. Relative bounds are resolved to absolute timestamps at parse time, and now()-relative queries bypass the result cache so a repeated "last 5m" never returns a stale window.

Note. Regex predicates are matched by an anchored Thompson-NFA engine — linear-time and non-backtracking, so hostile patterns cannot cause catastrophic backtracking (ReDoS). Also: SELECT * FROM cpu returns all cpu series, including labeled ones — use WHERE to filter.

GROUP BY & FILL

-- Time buckets
GROUP BY time_bucket(time, 1h)
GROUP BY time_bucket(time, 30s)
GROUP BY time_bucket(time, 86400000000000)   -- raw nanoseconds

-- Group by label ("avg per host")
GROUP BY host
GROUP BY host, time_bucket(time, 5m)        -- per-host buckets
GROUP BY host, region                        -- one group per (host, region) tuple
GROUP BY host, __metric__, time_bucket(time, 1h)   -- per (host, metric) buckets

-- Group by metric name across metrics (the __metric__ pseudo-label):
--   one group per matched metric; pair with a __metric__ matcher in WHERE
SELECT max(value) FROM cpu_usage_user
WHERE __metric__ =~ 'cpu_(usage_user|usage_system)' AND time >= S AND time < E
GROUP BY __metric__, time_bucket(time, 1h)

-- Fill empty buckets: FILL(0 | previous | linear | null)
GROUP BY time_bucket(time, 5m) FILL(previous)
GROUP BY time_bucket(time, 5m) FILL(0)

FILL applies to time-bucketed grouping and steps from one bucket start to the next — by the calendar unit, on the zone's clock, when the bucket is a calendar one; linear interpolates by the real offset. Policies: 0/zero, previous, linear; null (and any unrecognized policy) keeps gaps explicit — the default when FILL is absent.

Calendar buckets and time zones

-- Calendar buckets: hour / day are fixed widths, month / year real calendar months / years (UTC)
GROUP BY hour(time)                             -- == time_bucket(time, 1h)
GROUP BY month(time)                            -- UTC midnight on the 1st; February has 28 or 29 days
GROUP BY host, year(time) FILL(0)

-- On a zone's wall clock: an IANA name, a fixed offset, or a UTC alias
GROUP BY month(time, 'Europe/London')         -- local midnight on the 1st
GROUP BY day(time, 'America/New_York')        -- 23 or 25 h across a DST change
GROUP BY hour(time, 'Asia/Kolkata')           -- boundaries at :30 UTC
GROUP BY month(time, '+09:00')                -- a fixed offset; '-03:30', '+14:00'
GROUP BY month(time, 'UTC')                   -- == month(time); also 'Z', 'Etc/UTC', '+00:00'

Five bucketing functions. time_bucket(time, <duration>) is a fixed width aligned to the epoch and never takes a zone. hour, day, month and year take the field and an optional quoted zone: without one, hour and day are exactly time_bucket(time, 1h) / time_bucket(time, 1d), and month / year start at UTC midnight on the 1st / on January 1 (proleptic Gregorian, so February has 28 or 29 days). Any other argument shape — a duration on a calendar function, an identifier, a third argument, a missing ) — is a 400.

The zone is an IANA name in exact case ('Europe/London', 'Asia/Kolkata', 'America/Argentina/Buenos_Aires', 'Etc/GMT+5', whose +5 means UTC−5 by that family's convention), a fixed offset ±HH:MM or ±HH up to ±14:00, or a UTC alias ('UTC', 'Z', 'Etc/UTC', '+00:00') — which is the unzoned statement, byte for byte. Zone rules are read from the node's zoneinfo directory (TSDB_ZONEINFO_DIR, shipped in the published image), once per process: a node restarts to pick up a newer time zone database.

A bucket's ts is the UTC instant of its local boundary. Around a daylight-saving change the buckets follow the local clock as runs of instants: a bucket is the longest stretch of instants whose local clock reads the same hour, day, month or year, keyed by its first instant. A day is 23 hours when clocks go forward and 25 when they go back; a fall-back that keeps the clock inside the same hour is one two-hour hour bucket; a local midnight that never happens (Santiago, whose change is at 24:00) starts the day at the transition instant; a change that lands in a different hour starts a new bucket there. Boundaries follow the zone's historical offset, local mean time included. The answer is the same on every execution path and is cached separately from the unzoned statement; a fixed offset equals its zone only where the zone has no daylight saving ('+09:00' is 'Asia/Tokyo').

Refusals: a literal that is neither a well-formed name nor an offset is 400 invalid timezone (the body never echoes it); a well-formed name with no zone behind it is 400 unknown timezone '<name>'; a node whose zoneinfo directory is missing, or a zone file it refuses, answers 503 timezone_unavailable for names while offsets and aliases keep working. FORECAST needs a fixed stride, so it refuses month, year and every zoned bucket (forecast_requires_fixed_buckets).

Group-by-label responses are a groups array:

{"status":"ok","group_by":"host","groups":[
  {"labels":{"host":"web-1"},"value":15.0},
  {"labels":{"host":"web-2"},"value":150.0}
]}

Several labels (up to four, __metric__ allowed in any position) group by the tuple of their values: group_by becomes the array of keys in query order and each group's labels object carries every key; a series lacking one of the keys belongs to no group. More than four labels is a 400.

{"status":"ok","group_by":["host","__metric__"],"groups":[
  {"labels":{"host":"web-1","__metric__":"cpu_usage_user"},"value":15.0},
  {"labels":{"host":"web-1","__metric__":"cpu_usage_system"},"value":3.0}
]}

GROUP BY __metric__ groups by the metric name (stored as a __metric__ pseudo-label), so a single query can aggregate across many metrics and return one group per metric — the long form of a wide max(a), max(b), … row. Pair it with a WHERE __metric__ =~ '…' (or =) matcher to select which metrics fold in; the matcher is fully anchored. The candidate set spans every matched metric, and any time/value predicate is applied at read, not as a metric filter, so the grouping never collapses to the FROM metric.

High-cardinality labels are capped at 10,000 groups with a logged truncation warning. GROUP BY <label> and SHOW SERIES require the series index.


Ordering, Ranking, Pagination

GROUP BY host ORDER BY value DESC LIMIT 5     -- top 5 hosts by value
GROUP BY host TOPK 5                          -- shorthand for the above
GROUP BY host BOTTOMK(3)                      -- the 3 smallest
GROUP BY time_bucket(time, 5m) ORDER BY time DESC
SELECT DISTINCT * FROM cpu LIMIT 100
  • ORDER BY value|time [ASC|DESC] — default ASC. Value ordering applies to buckets and label groups; raw points order by time only.
  • TOPK n / BOTTOMK n — rank by value, keep the top/bottom n; parentheses around n are optional.
  • DISTINCT — dedup raw points by value.
  • LIMIT n [OFFSET m] — post-sort slice on buckets, groups, and points. Default limit 10000: larger result sets are capped unless a higher limit is given.

JOIN

SELECT avg(a.value) FROM cpu AS a
  JOIN memory AS b TOLERANCE 30s
  WHERE time > now() - 1h

Joins are time-aligned: points pair when their timestamps fall within TOLERANCE. ON time is accepted; any non-time join key is rejected — key/vector matching is not supported. In a cluster, cross-node joins fetch the remote side transparently.


SHOW Statements

SHOW METRICS                 -- {"status":"ok","metrics":["cpu","memory"]}
SHOW SERIES                  -- {"series":[{"metric":"cpu","labels":{"host":"web"}},...]}
SHOW LABEL VALUES host       -- {"label":"host","values":["web","db"]}

Served through /query like any other statement. Results are size-bounded.


Execution & Consistency

ZQL → Tokenizer → Parser (QueryAST) → Optimizer → Executor → JSON
  • Consistency. In HA mode, /query serves consistency=local reads (the default) on any node; consistency=linearizable runs a leader-only quorum read barrier first (GET /query?zql=...&consistency=linearizable; followers answer 503 not_leader with the leader hint); consistency=bounded serves only from replicas whose last leader contact is within max_staleness_ms (default 5000, clamped to [100, 60000]) — a stale replica answers 503 {"error":"stale_read",…} with a leader hint. Any other value is 400 unsupported_consistency.
  • Caching. Scalar aggregate results and final GROUP BY bucket lists are cached (LRU, keyed on the ZQL text) and invalidated on ingest, delete, and retention purge; now()-relative queries bypass the cache.
  • Streaming GROUP BY. GROUP BY time_bucket(...) with count/sum/avg/min/max/last/first/stddev — optionally with a value predicate, FILL, ORDER BY/LIMIT — folds one series at a time, so memory is O(buckets + one series window) however many series the metric has. rate/increase/percentile, GROUP BY <label>, JOIN, multi-column, window and forecast shapes materialize the merged stream.
  • Size limit. Materializing shapes estimate their in-window points before reading anything; above TSDB_QUERY_MAX_POINTS (default 20,000,000; 0 = off) the query is refused with 413 {"error":"query_too_large","points":N,"limit":L,"hint":"add a time range or label filter"}. GROUP BY <label> and forecast apply the cap per group.
  • Pushdown. Time and value predicates push down to the storage scan; label predicates use the inverted label index before any data is read.
  • Count acceleration. Metric-wide count(...) queries are answered from maintained sorted-run indexes whenever the result is provably exact (sub-millisecond on flushed data, even across millions of series); anything unprovable falls back to the authoritative scan. Exactness is verified continuously against closed-form oracles in the release gate.
  • Admission control. Metric-wide scans on a flushed store are limited to two in flight; excess queries receive an immediate 429 {"error":"query_capacity","retry_after_seconds":1} instead of queueing. Retry after the indicated delay — cheap queries (label-selected series, SHOW) are never limited.
  • Deadlines. Every query carries a server-side time budget (default 120 s); an over-budget scan returns an honest 504 query_deadline rather than holding a connection open indefinitely.
  • Errors are loud. An unknown function name anywhere in a statement is rejected at parse time with 400 {"error":"unknown function '<name>'"}; it never runs as an empty or defaulted aggregate. A non-finite aggregate is null inside bucket and group results and {"error":"NoDataFound"} for a scalar, so no response ever carries a nan/inf token.

For measured query and ingest performance, see the published benchmark methodology and results.


Examples

-- Average CPU for one host over the last 5 minutes
SELECT avg(value) FROM cpu_usage
WHERE host = 'web-1' AND time > now() - 5m

-- p95 latency per 5-minute bucket, gaps carried forward
SELECT percentile(value, 95) FROM request_latency
GROUP BY time_bucket(time, 5m) FILL(previous)

-- Top 5 hosts by average value
SELECT avg(value) FROM cpu_usage GROUP BY host TOPK 5

-- Per-second request rate over the last hour
SELECT rate(value) FROM http_requests WHERE time > now() - 1h

-- All web-* hosts except staging, in prod
SELECT avg(value) FROM cpu_usage
WHERE host =~ 'web-.*' AND NOT env = 'staging'

-- Mean and peak in one query
SELECT avg(value) AS mean, max(value) AS peak FROM cpu_usage

-- Hourly peak of every cpu_* metric in one statement
SELECT max(value) FROM cpu_usage_user
WHERE __metric__ =~ 'cpu_.*' AND time > now() - 1d
GROUP BY __metric__, time_bucket(time, 1h)

-- Forecast 24 steps ahead, one model per host
SELECT forecast(value, 24, 'ridgecv') FROM cpu_usage
WHERE time > now() - 7d
GROUP BY host