@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.
;. The corpus convention is UPPERCASE
keywords and lowercase identifiers; identifier case is not significant.
PARAM: program inputs
PARAM('name'), and it is bound out-of-band as a real
query parameter, never substituted into the SQL text:
- A non-
TEXTparameter needs an explicit cast at the point of use:CAST(PARAM('want_date') AS DATE). - A bare
want_datein compute code is an undeclared-variable error — parameters are read-only inputs to your SQL, not variables. The one compute-side position wherePARAM('name')is legal is aDECLAREinitializer (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:
- 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
PARAMor procedure argument — that collision is a compile error.
DECLARE: variables and carried state
:= 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: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 theORDER KEY(the sameWHERE key > last ... LIMIT Npattern 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 (aGROUP BYsource re-evaluates per chunk underBATCH SIZE N) and for order-independent bodies. On the client-compute path the whole set is held in memory.
ORDER KEY
ORDER KEY (...) declares a strict total order over the read. It is
required when:
BATCH SIZE Nchunks 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.
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: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 aCOMMIT. 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:
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
ASELECT ... INTO may stand directly in the batch body — a
per-batch scalar re-fetch, one extra round trip per batch (never
per row):
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:
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 naturalUPDATE, 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:
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:
SETof a merge-key column, or aSET ROWmatched 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
UPDATEplus a complementINSERT ... 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:
- A write with no
COMMITreachable downstream on any path is a compile error — there is no implicit commit to fall back on. (ACOMMITthat exists but sits behind a runtime gate is legal — see below.) COMMITcannot appear inside a row loop, or inside a counted/WHILEloop 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 Nmust commit in the batch body, unconditionally — thatCOMMITis each chunk’s transaction bracket.
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 interleavedSELECT 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/CASEis spanned as plain control flow — the arms inherit the transaction state, and writes andCOMMITare 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 atEND— the same discard an earlyRETURNhas 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
COMMITcommits 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’sCOMMITnever fires — a pending earlier write then rolls back atENDunless a later explicitCOMMITcommits it.
Pipelines: repeating a read-compute-write
A counted orWHILE 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 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
WHILEpipeline’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 aWHILEover a value the program re-reads each pass. - A
COMMITin 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/CONTINUEat 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):
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 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/JOINand as the target of a row loop’s capturedUPDATE/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 companionsumatra_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).
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.