Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Asking

An ask is a JSON object sent to POST /ask: a query and its parameters. The examples ask the usage view of Writing a spec. This one asks for purchases in two regions per hour, from 9:00 to 12:00 UTC:

curl --json '{
  "query": "SELECT bucket, region, events, amount FROM usage WHERE kind = $1 AND region IN ($2, $3) AND bucket >= $4 AND bucket < $5 GROUP BY bucket, region",
  "parameters": ["purchase", "eu", "us", "2026-11-13T09:00:00Z", "2026-11-13T12:00:00Z"]
}' http://localhost:8080/ask
FieldRequiredValue
queryyesOne SELECT over a view, in the subset below.
parametersnoThe values for $1, $2 and on, in order.

The query

SELECT [key, ...] measure, ...
FROM view
WHERE [condition AND ...] bucket >= $n AND bucket < $n
[GROUP BY key, ...]
[HAVING comparison [AND ...]]

condition   key = $n
            key IN ($n, $n, ...)
comparison  measure { = | <> | < | <= | > | >= } $n

The notation is that of the spec’s synopsis, and this is the whole language: anything it does not show is an error that names the construct and says what to write. The shortest ask:

SELECT events, amount FROM usage WHERE bucket >= $1 AND bucket < $2
  • A key or a measure goes by the name the view gives it with AS: region, kind, account_id, amount. a.region is not a name an ask can use; neither is a, the join, nor a table name. bucket is the view’s time bucket, and a key like any other in SELECT and GROUP BY.
  • Each name is selected at most once, since the names become the fields of each answer row. GROUP BY names exactly the selected keys.
  • bucket >= $n and bucket < $n bound the range, once each, and both are written: an ask says what it reads. They and the conditions come in any order. No condition is put in parentheses.

HAVING

WHERE picks the rows by their keys; HAVING picks the groups by their measures, after Straum has added them up. This asks for the regions with more than 40 purchases in an hour, and their amounts:

SELECT bucket, region, events, amount FROM usage WHERE kind = $1 AND bucket >= $2 AND bucket < $3 GROUP BY bucket, region HAVING events > $4
  • A comparison names a measure the ask selects, so the answer shows every value a group was kept for. A key goes in WHERE, a measure in HAVING; each refuses the other and says where it goes.
  • Not equal is <>; != is refused. Comparisons join by AND.
  • A measure that is null meets no comparison, <> included, as in PostgreSQL, so its group is left out.
  • A count, sum, min, max, percentile or average compares with a number parameter, exactly: avg_amount > 44.27 is true of 44.273058, and amount = 1875.7 of 1875.70. Up to 38 digits may follow the point.
  • bool_and and bool_or compare with a boolean, by = or <> only.
  • An ask without keys answers its one row only if it meets every comparison; otherwise none.

Parameters

Every value is a parameter: a value written into the query, such as region = 'eu', is an error, so a value can never change what the query says. Parameters are numbers, strings or booleans, $1 first. Each must be used, and one can be used more than once. A number with a fraction or exponent is read exactly as written, never as a float: 44.27 is 4427 hundredths.

A parameter must have its column’s type, with no conversion: an integer for an integer column or a numeric(p, 0), a string for text, a boolean for a boolean or a null-ness key. account_id = $1 with "7" is an error, not an answer with no rows. A numeric key with a scale above 0 can’t be filtered on: at scale 2, 1 could mean 1 or 0.01. A HAVING comparison takes a number of any scale.

The bucket bounds are RFC 3339 times, such as "2026-11-13T00:00:00-05:00", or Unix seconds. Both must fall on an edge of the view’s date_bin buckets: Straum never answers for part of a bucket.

The answer

{
  "as_of": "2026-11-14T09:15:02.530117842Z",
  "rows": [
    {"bucket": "2026-11-13T09:00:00Z", "region": "eu", "events": 38, "amount": 1875.70},
    {"bucket": "2026-11-13T09:00:00Z", "region": "us", "events": 61, "amount": 2980.00},
    {"bucket": "2026-11-13T10:00:00Z", "region": "eu", "events": 44, "amount": 1530.20},
    {"bucket": "2026-11-13T10:00:00Z", "region": "us", "events": 57, "amount": 2210.45},
    {"bucket": "2026-11-13T11:00:00Z", "region": "eu", "events": 29, "amount": 980.00},
    {"bucket": "2026-11-13T11:00:00Z", "region": "us", "events": 70, "amount": 3125.60}
  ]
}
  • as_of: every commit made before this time is in the answer. It is Straum’s wall time when it last confirmed that it had applied all WAL PostgreSQL had written, so it keeps moving on a quiet database. A transaction is applied whole, so an answer never sees half of one. An answer can predate a client’s commit (Seeing your own write).
  • rows: one object per group, keyed by the names in SELECT, sorted by key, nulls first. An ask without keys answers one row even when nothing matches, as SQL does without GROUP BY: counts are 0, the rest null.

timestamptz buckets are their start in UTC. timestamp buckets are wall clock readings formatted with Z. Measures print as the measures table says: 1875.70 for a numeric(12, 2) sum, and 44.273058 for an avg_amount of 18240.50 over 412 events, at four places past the scale. A row whose dim row does not exist has null dim columns, as a LEFT OUTER JOIN gives, until the dim row arrives.

Two headers help debugging. Straum-LSN names the last commit the answer reflects. Server-Timing says where the time went in Straum, in milliseconds: parse, answer, json and total; what a client measures beyond total is the network and the client itself.

As a table

An ask sent with Accept: text/plain is answered in plain text, for people: the same rows as a table, then the row count and as_of, as straum ask prints it. Its errors are plain text too, a line each, under the same statuses. The layout is for reading and may change; a program reads the JSON.

Seeing your own write

Straum-LSN is written as PostgreSQL writes a pg_lsn, such as 0/1A2B3C8. A client that needs its own write in the answer can read pg_current_wal_lsn() on the same connection right after its commit, then ask until the header reaches it:

COMMIT;
SELECT pg_current_wal_lsn();  -- 0/1A2B3C8
SELECT '0/1A2B3D0'::pg_lsn >= '0/1A2B3C8'::pg_lsn;  -- Straum-LSN >= mine

Compare as pg_lsn or as two hexadecimal numbers, never as strings. Once the header is at or past the LSN read, the answer contains the write. Under synchronous_commit = off the commit may not be written yet, so read pg_current_wal_insert_lsn() instead.

The test can only say yes. pg_current_wal_lsn() counts all WAL, including tables Straum doesn’t follow, while the header moves only when Straum applies a commit. On a quiet database the header can stay below the LSN read even though the write is in, so give up after a timeout or fall back to as_of.

Errors

StatusWhen
200The answer.
400The ask is wrong; errors lists every problem.
405Not a POST.
413The ask is over 1 MiB.
500Straum failed while answering; its log says why.
503{"errors": ["not ready"]}: Straum is loading. Retry after /readyz returns 200.
{"errors": ["unknown measure x", "zip is not a key of the view"]}
{"errors": ["line 1: an ask writes both bounds; bucket < $n is missing"]}
{"errors": ["unknown field \"tz\""]}
{"errors": ["line 1: value, use a parameter such as $1, `'eu'` is not in Straum SQL, the PostgreSQL subset made only for Straum"]}
{"errors": ["bound 1794558600 is not on a 3600s bucket edge"]}
{"errors": ["account_id is an integer, so it can't be the text \"7\""]}