Skip to main content
A Sumatra program is a single file. Two kinds exist: the anonymous block (run one-shot with @file.sumatra) and the stored procedure (the same body wrapped in CREATE OR REPLACE PROCEDURE, covered in Stored procedures). This page walks the anonymous block top to bottom.
Every statement ends with ;. The corpus convention is UPPERCASE keywords and lowercase identifiers; identifier case is not significant.

PARAM: program inputs

Parameters are declared before the body, one per line, and their values arrive on the command line in declaration order:
A parameter is referenced only inside SQL — reads and write statements — as PARAM('name'), and it is bound out-of-band as a real query parameter, never substituted into the SQL text:
Two rules follow from that design:
  • A non-TEXT parameter needs an explicit cast at the point of use: CAST(PARAM('want_date') AS DATE).
  • A bare want_date in compute code is an undeclared-variable error — parameters are read-only inputs to your SQL, not variables. The one compute-side position where PARAM('name') is legal is a DECLARE initializer (cutoff NUMERIC(12,2) := PARAM('cutoff');), where it takes the declared type with no cast — the idiom for seeding a carried variable from a program input.

Your variables are visible to your SQL

PARAM('name') resolves against more than the declared parameters: every declared scalar variable is published into every server statement’s bind list, under its own (lowercase) name. A read or a write statement can therefore filter on a value the program computed earlier in the run:
That is the watermark pattern: derive a cursor from the target, filter the next read by it, advance it as part of the run — an incremental job with no command-line arguments at all. The rules:
  • The binding is per-execution: a variable that is unset or NULL at the moment a statement runs is simply absent from that statement’s binds, and referencing it then is the engine’s own loud “parameter was not set” error — never a silent NULL.
  • PARAM() returns text, so a non-text variable is read back with a cast, exactly like an argument.
  • A variable may not share a name with a declared PARAM or procedure argument — that collision is a compile error.

DECLARE: variables and carried state

Variables are program-scoped: they carry across rows, across batches, and across sequential read blocks — nothing ever resets them implicitly. That is what makes multi-pass and running-state programs natural to write. Initializers are optional, but reading a variable before it has ever been assigned is a compile error (definite-assignment analysis, the same rule as Java or Rust) — never a silent NULL. Give accumulators an explicit := 0. An initializer may also be PARAM('name') (typed by the declaration), and a record variable takes a positional literal — see records. DECLARE is also where record and collection types live — see records and collections.

The read block

All row input comes through one construct:
The parenthesized SELECT is opaque: Sumatra relays it to the engine byte-verbatim and learns its column names and types at compile time from the live database. Filter, join, aggregate, use window functions — anything the engine accepts is fine, and any of the engine’s functions may appear inside it. Under BATCH SIZE N with a row loop and a per-batch COMMIT, each cycle of the loop is one round of this conversation:

BATCH SIZE

BATCH SIZE is mandatory on every read and sets the working grain:
  • BATCH SIZE N — fetch the result in chunks of up to N rows, using keyset pagination over the ORDER KEY (the same WHERE key > last ... LIMIT N pattern you would hand-write). Each batch is processed and written as a unit, and memory stays bounded at N rows.
  • BATCH SIZE ALL — one whole-set batch, evaluated once. This is the right choice for aggregating reads (a GROUP BY source re-evaluates per chunk under BATCH SIZE N) and for order-independent bodies. On the client-compute path the whole set is held in memory.
An empty read is a clean no-op: the loop body runs zero times and the program continues.

ORDER KEY

ORDER KEY (...) declares a strict total order over the read. It is required when:
  • BATCH SIZE N chunks the read (the key is the pagination cursor), or
  • the body is order-dependent — carried state that must see rows in a specific sequence — at any batch size, or
  • the loop is a writing client-compute loop.
It may be omitted only for order-independent, whole-set (ALL) work. Key columns must be INT, BIGINT, or TIMESTAMP (composite keys are fine, e.g. ORDER KEY (customer_id, entry_id)). The order must actually be strict: a duplicate key value or a NULL in a key column at runtime is a loud error — never a silently skipped or double-processed row.

Row loops and batch statements

Inside the batch body you either iterate rows in memory:
or reference the batch as a set in SQL statements (no row loop), which keeps the work server-side:
Reads do not nest: one read block inside another is a compile error (join or use a keyed collection instead), and a read cannot appear inside a row, counted, or WHILE loop in compute position. Sequential read blocks are fine and share carried variables — the two-pass pattern — and a top-level counted or WHILE loop may contain whole read blocks: that is the pipeline.

The batch body beyond the row loop

The batch-loop body is not limited to one row loop plus a COMMIT. It also accepts assignments over carried variables, IF/CASE statements, counted and WHILE loops (in-memory compute), a bare RETURN;, and per-batch lookups — so per-batch setup, per-batch decisions, and a gated checkpoint are all expressible in place:
Two walls hold inside the batch body: rec fields exist only inside the row loop (between batches there is no current row), and a counted or WHILE loop here is still in-memory compute — no writes, reads, or COMMIT inside it. On the server-side route (no row loop) the per-chunk COMMIT is the chunk’s transaction bracket and must remain unconditional.

Lookups inside the batch loop

A SELECT ... INTO may stand directly in the batch body — a per-batch scalar re-fetch, one extra round trip per batch (never per row):
Position is meaning: before the row loop, the fresh value feeds that batch’s compute; after the COMMIT, it reads the batch’s committed state. On the server-side route the lookup runs inside the chunk’s transaction and sees the chunk’s own uncommitted writes. A miss raises NO_DATA_FOUND as usual — the committed prefix stays. (BULK COLLECT does not join it: a keyed map loads once, at the top level.)

Stopping a batched read early

EXIT and CONTINUE (optionally WHEN pred) work at the batch-loop boundary itself on the client-compute route with BATCH SIZE N: EXIT stops pulling batches; CONTINUE skips to the next batch. The predicate is evaluated over carried variables between batches:
Placement against the in-body COMMIT decides the in-flight batch’s fate: an EXIT before it rolls that batch back (prior batches stay durable); after it, the batch is committed first. On the server-side route, and at BATCH SIZE ALL (one whole-set batch — nothing to stop), batch-boundary EXIT/CONTINUE are compile errors; gate the whole read with IF ... THEN <read> END IF; instead.

How writes are routed

You write only natural UPDATE, INSERT, and DELETE. The compiler routes each write to the lightest correct SQL form — there is no routing keyword, and you never write MERGE, COPY, or staging tables: The per-row forms and their rules:
A program may mix them — an IF that deletes one row, updates another, and inserts a third produces a single op-tagged change set applied by one MERGE. The merge key is whatever the write’s WHERE equates with the record’s own fields — a single column or a composite AND-chain (WHERE wh = rec.wh AND sku = rec.sku). Any extra non-key condition on rec fields acts as a capture filter: the change is captured only when it is TRUE (WHERE id = rec.id AND rec.qty > 0 skips non-positive rows). Three rules the compiler holds you to:
  • A write may not change its own key: SET of a merge-key column, or a SET ROW matched on anything but the record’s own key field, is a compile error.
  • One change per key per flush: writing the same key twice in one batch is a program error. Statically visible cases are compile errors; data-dependent duplicates surface as the engine’s own MERGE cardinality error at runtime (catchable with WHEN OTHERS). Repeated DELETEs of the same key are idempotent and allowed.
  • An upsert is written as its two natural statements — an accumulate UPDATE plus a complement INSERT ... WHERE key NOT IN (...) — and both run server-side; together they behave as the upsert.

COMMIT: placement is meaning

COMMIT is always explicit — Sumatra never commits behind your back — and where you put it defines the transaction grain. There are three grains:
Per-batch commits give you bounded memory and a durable prefix: if the program faults on batch 40, batches 1–39 stay committed, and a re-run picks up the remainder (see Exceptions and recovery). The at-end grain trades that for all-or-nothing atomicity. The compiler enforces coherence:
  • A write with no COMMIT reachable downstream on any path is a compile error — there is no implicit commit to fall back on. (A COMMIT that exists but sits behind a runtime gate is legal — see below.)
  • COMMIT cannot appear inside a row loop, or inside a counted/WHILE loop in compute position — per-row commits are inexpressible by grammar. At the top level of a pipeline loop it is the per-iteration grain.
  • A server-side read at BATCH SIZE N must commit in the batch body, unconditionally — that COMMIT is each chunk’s transaction bracket.
Writes persist iff control reaches a COMMIT: a fault or crash rolls back the open transaction while every already-committed batch survives. On Firebolt, transactions are ACID and snapshot-isolated, so readers never see a half-written batch.

How top-level statements share a transaction

Top-level statements execute eagerly, at their body position, in one open transaction: the first write opens it, every later statement — including an interleaved SELECT INTO or BULK COLLECT — runs inside it and sees the transaction’s own uncommitted writes, and your explicit COMMIT closes it. A fault or a RETURN before the COMMIT rolls the whole open transaction back. A COMMIT with nothing open is a legal no-op. The open transaction flows through structure rather than fencing it:
  • An IF/CASE is spanned as plain control flow — the arms inherit the transaction state, and writes and COMMIT are legal inside an arm. A gated commit (IF ok THEN COMMIT; END IF;) is a valid construct: on the run where the gate is false, the tail of the transaction simply rolls back at END — the same discard an early RETURN has always meant. Lookups in an arm run zero times or once, and a miss behind a false gate is not a fault.
  • A batch loop that starts while a transaction is open joins it on batch 1: the first batch’s COMMIT commits the pending write(s) together with that batch, and later batches bracket per batch as usual. One consequence to know: if the read matches zero rows, the loop’s COMMIT never fires — a pending earlier write then rolls back at END unless a later explicit COMMIT commits it.

Pipelines: repeating a read-compute-write

A counted or WHILE loop at the top level of the program is a pipeline: its body takes the full top-level alphabet — whole read blocks, writes, lookups, nested IF/CASE, and its own COMMIT — and runs once per iteration. This is the shape for “do the whole pass once per month / per region / until the backlog is drained”:
The rules that make pipelines predictable:
  • The loop variable is visible to the body’s SQL as PARAM('<var>') — bound per iteration like any published variable. It may not collide with a declared parameter or variable name.
  • Loop bounds re-evaluate every iteration, and a WHILE pipeline’s predicate is over carried variables — which a per-batch or top-level lookup can update, so “repeat until the data says done” is written as a WHILE over a value the program re-reads each pass.
  • A COMMIT in the pipeline body is the per-iteration grain: it closes that iteration’s transaction (and drains that iteration’s staged write-backs). An at-end read whose staged write-back would only be committed by a later iteration is a compile error — each iteration settles its own work.
  • EXIT / CONTINUE at pipeline level bind to the pipeline loop — the innermost-loop rule, unchanged.
  • The grain is honest: N iterations mean N read-compute-write passes — the bound is the program’s own (a range or a predicate over carried state), never a per-row server call.

Dynamic table names

A program can take a table name as an input — same logic, different target table per run — with a %TABLE parameter (or procedure argument):
The declaration names a reference table (daily_rollup): the program compiles against that table’s shape, and at run time the argument supplies the actual name. Every PARAM('tgt') of a %TABLE parameter is a substitution of that name — the opposite treatment from a value parameter, decided by the declaration, never by position:
The guardrails that keep this safe:
  • The runtime name must be a plain identifier (lowercase word characters, at most 128 bytes). Quotes, spaces, dots, and semicolons are unspellable — a dynamic name can be anything a static name could have been, and nothing more, which closes off injection by construction.
  • At run start the actual table’s shape is checked against the reference table’s compiled shape; a drifted table is refused, never run against stale assumptions.
  • A name parameter may appear in the opaque read’s FROM/JOIN and as the target of a row loop’s captured UPDATE/INSERT/DELETE. Everywhere else — a value position, a top-level set-statement target — it is a compile error.

What the compiler manages in your database

Two pieces of engine-side plumbing exist so you know what they are:
  • Staging tables — client-computed changes land in a per-run scratch table named sumatra_stage_... before the MERGE applies them. They are created, truncated, and dropped automatically; a crashed run’s orphan is reclaimed by the next run of the same program. A companion sumatra_stage_lock_... marker refuses two concurrent runs of the same program with the same parameters.
  • sumatra_source — the stored-procedure catalog table (only created once you store a procedure).
Treat the sumatra_ name prefix as reserved for the toolchain in databases you point Sumatra at.

Program lifecycle

A run finishes in exactly one of three states, surfaced as the process exit code: 0 completed, 2 stopped by a handler with the committed prefix retained, 1 error. There is no automatic retry or resume — re-running a program is always your action, and the standard idiom is to make the read re-runnable (WHERE processed = FALSE, flipping the flag as part of each row’s write) so a re-run continues where the committed prefix ends.