Skip to main content
A Sumatra program contains two dialects. Inside a read block’s parentheses and inside write statements you are writing SQL — the connected engine’s own dialect (Firebolt, in these docs’ examples), relayed verbatim. Between them, in the procedural middle, you are writing Sumatra’s compute dialect — whose value semantics are deliberately identical to the engine’s, down to rounding and overflow, so moving an expression between the two never changes the answer.

Types

Prefer the spellings DOUBLE and REAL. Firebolt’s own FLOAT keyword is a 64-bit alias, so writing FLOAT invites confusion about width; the corpus convention is DOUBLE for 64-bit and REAL for 32-bit.
Carry vs. compute. Every type can be carried — read from the engine and written back — as long as the value stays on the server side (a read column passed through INSERT ... SELECT FROM batch). Only the scalar types above the line participate in compute — in-memory expressions and variables. BYTEA and ARRAY cannot enter a row loop’s in-memory record, and arithmetic on a carry-only type is a compile error, not a silent coercion.

Literals

  • Integer literals are typed by magnitude: a value that fits in 32 bits is INT, otherwise BIGINT. Beyond 64 bits, write it in decimal form (9300000000000000000.0). Scientific notation is not accepted.
  • A literal with a decimal point is NUMERIC with the written scale (4.00 is NUMERIC scale 2).
  • Strings are single-quoted: 'shipped'.
  • Booleans are TRUE and FALSE.
  • Dates and timestamps: DATE '2026-07-01', TIMESTAMP '2026-07-01 12:30:00'.
  • A NULL that needs a type is written CAST(NULL AS T).

Variables and assignment

Assignment is :=; plain = is SQL’s comparison and SET operator, used only inside SQL statements.
Reading a variable before it has ever been assigned is a compile error. Assignment converts the value to the declared type; converting a float to an integer rounds half-even (2.52, 3.54).

Operators

Precedence is conventional (OR < AND < NOT < comparison < additive < multiplicative); parenthesize anything you would parenthesize in SQL.
Inequality has two spellings — <> and != — and they are one and the same operator from the lexer on. A ! that is not part of != is not Sumatra syntax. Chained comparison (a = b = c) is a parse error — parenthesize what you mean.
A CASE expression selects a value and works anywhere a value does:

Numeric semantics

Sumatra’s arithmetic is the bound engine’s arithmetic, bit for bit — Firebolt’s, in these docs’ examples:
  • Integers are width-checked at every + - * and every cast: INT math stays INT and faults with OVERFLOW past 32 bits; mixing widths promotes (INT * BIGINT is BIGINT).
  • Decimals follow the engine’s strict typing: the result scale of + - * / is the widest operand scale, precision grows to fit and is capped at 38, and exceeding it is a loud overflow. Multiplication and division are computed in a wide intermediate and rounded half-up to that scale.
  • Floats are IEEE 754 with no fused-multiply-add surprises. Mixing DOUBLE with NUMERIC or integers promotes to DOUBLE. A result that would be NaN or Infinity faults instead.
  • Division by zero faults with ZERO_DIVIDE — never NULL, never Infinity.
One deliberate strictness: operands of CASE arms, GREATEST, and LEAST must share a type family. Mixing TEXT with a numeric or temporal operand is a compile error asking for an explicit CAST, where the engine alone would coerce at runtime and fault only on bad data. Casts that cross into or out of TEXT are not available in compute position — do text parsing and formatting in the read’s SQL, where the engine’s full conversion machinery applies.

NULL semantics

Sumatra uses SQL’s three-valued logic, unchanged:
  • NULL propagates through operators and functions: NULL + 1 is NULL, NULL || 'x' is NULL, comparisons with NULL are NULL.
  • Test with IS NULL, or compare null-safely with IS [NOT] DISTINCT FROM.
  • A condition that evaluates to NULL is not TRUE: an IF takes its ELSE path, and a capture filter skips the row.
NULLs are restricted in exactly three places: ORDER KEY columns (a NULL key is a loud runtime error), keyed-collection keys (NULL never matches; a NULL-key probe raises NO_DATA_FOUND), and merge-key matching (a NULL key component never matches a target row).

Control flow

  • CONTINUE WHEN pred; (or bare CONTINUE;) skips to the next iteration.
  • EXIT WHEN pred; (or bare EXIT;) leaves the innermost loop. Both also work at the batch-loop boundary itself — see stopping a batched read early.
  • RETURN; ends the whole program cleanly (exit 0) from anywhere; writes persist only up to the last COMMIT that was reached.
Every loop closes with END LOOP; — batch reads, row loops, counted loops, and WHILE alike. What a counted or WHILE loop’s body may contain depends on where the loop stands:
  • Inside a row loop (compute position): the body is in-memory compute — a read block or a COMMIT inside it is a compile error by design, so a per-row server operation stays unwriteable.
  • At the top level of the program: the loop is a pipeline — its body takes the full top-level alphabet: whole read blocks, writes, lookups, and its own per-iteration COMMIT. See pipelines.

Records

Declare a record type explicitly, or derive one from a table. A record variable can be initialized with a positional literal — ev := (1001, 'shipped', 25.00, CURRENT_TIMESTAMP); — matched one-to-one against its field list (exact arity, each element converted to its field’s type, a bare NULL taking the field’s type); the literal form exists only in a DECLARE initializer.
The rows of a read (rec in FOR rec IN batch) are records too — their fields are the SELECT’s columns, and they are assignable.

Collections

Sumatra’s collection is the keyed map: a whole table (or filtered subset) loaded into memory once and probed by key — the lookup-table pattern.
  • INDEX BY names the key column(s) of the element type — composite keys are supported (INDEX BY wh, sku), and the probe key can be a runtime-computed value, the one correlation no JOIN can express.
  • A probe on a missing key (or a NULL key) raises NO_DATA_FOUND — handleable like any fault.
  • The map is a read-only lookup: there is no iteration over a collection, and no .COUNT-style methods. It is resident in memory, so load dimensions, not fact tables.
  • Key columns may be integer, decimal, text, date, or timestamp — not float.
For a single-row lookup without a map, SELECT ... INTO fetches scalars directly; zero rows raises NO_DATA_FOUND, and more than one row is a loud error:
A lookup is one server round trip, so where it may stand is governed by how often it would repeat: at the top level (and inside a top-level IF/CASE arm, where it runs zero times or once), or directly in a batch loop’s body — one re-fetch per batch. Inside a row, counted, or WHILE loop it is rejected: that would be a per-iteration server call. See lookups inside the batch loop.

Identifiers and names

  • Identifiers are ASCII: letters, digits, underscore, not starting with a digit. Case is not significant; the convention is UPPERCASE keywords and lowercase identifiers.
  • Table names are single bare identifiers — there is no schema.table qualification. A table or column whose name needs quoting in the engine can be reached by aliasing it inside the read’s SQL.
  • Reserved words cannot name variables, types, record fields, parameters, or procedures, and the check is case-insensitive. Three groups are reserved: every language keyword (including the type names, TRUE/FALSE, CURRENT_DATE/CURRENT_TIMESTAMP, STOP, the exception names, and the reserved-for-future words MERGE, FUNCTION, SCAN, FOLD, MAP, FILTER, COLLAPSE); the C++ keyword table, adopted wholesale and frozen (so class, template, new, … are rejected — the error says “reserved word” either way); and any name starting with the __sumatra_ prefix. The one deliberate non-reserved keyword is BATCH, so the canonical FOR batch IN (...) idiom can name its loop variable batch.