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) / Δsecondsover the range (assumes a monotonic counter).increase(value)— total rise, tolerating counter resets.percentile(value, q)— exact percentile withqin[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,lastreturns the largest of the tied values andfirstthe smallest, so the answer does not depend on merge order. UnderGROUP 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 byn/(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.
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]— defaultASC. Value ordering applies to buckets and label groups; raw points order by time only.TOPK n/BOTTOMK n— rank by value, keep the top/bottomn; parentheses aroundnare 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,
/queryservesconsistency=localreads (the default) on any node;consistency=linearizableruns a leader-only quorum read barrier first (GET /query?zql=...&consistency=linearizable; followers answer503 not_leaderwith the leader hint);consistency=boundedserves only from replicas whose last leader contact is withinmax_staleness_ms(default 5000, clamped to [100, 60000]) — a stale replica answers503 {"error":"stale_read",…}with a leader hint. Any other value is400 unsupported_consistency. - Caching. Scalar aggregate results and final
GROUP BYbucket 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(...)withcount/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 with413 {"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_deadlinerather 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 isnullinside bucket and group results and{"error":"NoDataFound"}for a scalar, so no response ever carries anan/inftoken.
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