Transformation without leaving the database
The conventional answer to procedural transformation is a round trip: export the data to an external compute layer (a Spark cluster, a fleet of Python jobs), transform it there, and load the results back. Every part of that round trip is a cost you keep paying:- Data movement, twice. The working set crosses the wire out, and the results cross back in — often the slowest and most expensive part of the pipeline.
- A second platform to operate. The external layer has its own sizing, patching, monitoring, credentials, and failure modes. For many teams the cluster exists only to host the procedural steps the database couldn’t run.
- Two failure domains. A half-finished external job leaves state split across systems, and recovery logic has to reason about both.
- A wider security surface. Data now rests, at least transiently, outside the database — a second system to bring inside audit and compliance boundaries.
- Schema drift. The external job carries its own copy of schema knowledge, which silently rots as tables evolve.
- The program compiles against the live catalog of the connected database — the database itself is the schema, so there is no copy to drift. If the schema changes after compile, the program refuses to run on stale assumptions and recompiles.
- Reads and writes are the engine’s own SQL. Set-shaped work never leaves the engine at all; procedural work streams through the program in bounded batches and lands back as set-based writes. Rows are never parked in a second system of record.
- The runtime is one native binary on a host you already have — a process, not a platform. There is no cluster, no job framework, no scheduler service; cron and an exit code are enough.
- Failure semantics live in one place: committed batches are durable in the database, everything else rolled back — one system to reason about when something goes wrong.
The batch engine
Sumatra’s execution grain is the batch — and one declaration sets it:BATCH SIZE is one knob controlling four things at once — the
read chunk, the write flush, the commit checkpoint, and the memory
bound. Behind it, the compiler generates the machinery you would
otherwise hand-build:
Keyset pagination, generated. Chunked reads page by key, not by
offset — the shape of the generated fetch is:
OFFSET: no quadratic re-scans, no scratch tables, no
manufactured row numbers. You declare the key; the cursor logic,
the boundary tracking, and the end-of-set detection are generated.
An order you can trust. ORDER KEY declares a strict total order,
and the toolchain treats it as a contract: a duplicate key or a NULL
in a key column at runtime is a loud error — never a silently skipped
or double-processed row. Carried state flows across batch seams in
declared order, which is what makes a 50,000-row-batched running
balance produce the identical answer to a single-pass run. That
invariance — the same result at any batch size — is a language
guarantee on the compute path, not a hope.
Checkpoints for free. A COMMIT inside the batch loop makes every
batch a durable checkpoint. A ten-hour backfill that faults at hour
nine keeps nine hours of committed work as a valid prefix, and the
standard done-flag idiom makes the re-run resume exactly where it
stopped — restartability falls out of the commit grain instead of
being engineered separately.
Bounded memory. BATCH SIZE N caps the client working set at N
rows regardless of table size; BATCH SIZE ALL is the explicit
whole-set choice for aggregating reads, where one distributed
evaluation of the query is the efficient shape.
And the row-at-a-time trap is fenced off. On a distributed
columnar engine, per-row server calls — the natural idiom on OLTP
databases — are catastrophically slow. Sumatra does not merely
discourage them; the grammar has no way to write a per-row server
query or a per-row commit. The language’s promise is per-row logic
at set-based cost, and the promise is structural.
The merge engine
The write side is where hand-rolled pipelines hide their sharpest edges. Applying a mixed stream of inserts, updates, and deletes correctly means writing a stagedMERGE: create a scratch table, bulk
it full, get the match conditions and the operation arms exactly
right, clean it all up — and get it right again in every job that
writes.
In Sumatra you write only natural per-row statements:
- One statement per batch, whatever the mix. Ten thousand rows’ worth of per-row branching — some deleted, some updated, some inserted — lands as a single set-based apply, at set-based cost.
- The staging lifecycle is invisible. The scratch table is created, filled in bulk over the same SQL connection (no CSV export, no object-store hop, no COPY pipeline to build), applied, and dropped automatically. A crashed run’s orphan staging table is reclaimed by the next run of the same program.
- Only what changed is written. A single-column
UPDATEstages just the key and that column — a sparse update that leaves every other column untouched and lets the read project only what it needs.SET ROWwrites the whole record when that is what you mean. - The lightest form wins. Captured-and-staged MERGE is the heaviest route, used only when per-row compute demands it. A write that is already set-shaped runs as plain DML exactly as you wrote it, and a write that correlates to the batch as a set runs as one correlated statement — the compiler always routes each write to the cheapest form that is correct, with no keyword and no hints.
- One change per key per flush. Two captured changes to the same merge key in one batch is a program error surfaced loudly — never a silent last-write-wins that quietly picks a survivor.
- Keys are immutable in a write. A statement that would change its own merge key is rejected at compile time, because a staged merge cannot express it correctly.
- NULL keys never match, exactly as SQL joins treat them — no accidental cross-matching through NULLs.
Built to run unattended
The same machinery composes into an operational shape schedulers can rely on. Every run ends in exactly one of three states —0 completed,
2 stopped cleanly with the committed prefix retained, 1 needs a
human — so cron can branch without parsing logs. Stored procedures
compile once and run from cache on any host, recompiling only when
source, toolchain, or schema actually changed — and each recompile is
announced. Concurrent duplicate runs of the same procedure with the
same arguments are refused engine-side, so an overlapping cron slot
cannot double-apply a batch.
Engine-neutral by construction
Sumatra deliberately defines program meaning as the connected engine’s meaning: values, rounding, overflow, and NULL behavior are the bound backend’s semantics, verified bit-exact, and everything engine-specific in the toolchain — type names, SQL dialect, semantic constants — lives behind per-backend profiles rather than in the language. Your read SQL is your engine’s own dialect, relayed verbatim. The backend is declared by the DSN’s URL scheme, per database. Firebolt is the first live backend and the engine used throughout these docs; the language surface you learn — batches, routing, merge discipline, exceptions — is the portable part, designed to carry to further engines as they are bound.See it in practice
- Quickstart — run the round trip yourself in ten minutes.
- Learning by example — the running balance, change-feed apply, and fail-safe scoring programs.
- Programs and batches — the precise rules behind the batch and merge machinery described here.
