Scenario-Tree Testing in PostgreSQL: Every Authored Branch, Shared History, Before COMMIT#
Express the branching scenarios of your business logic as a directory tree; walk it with savepoints so each branch inherits its history instead of rebuilding it; and let the walk decide whether your deployment commits.
By Alexey Evlampiev
Abstract. Database tests often repeat the same state-building work, because several
scenarios share the same prefix: a device may be provisioned before testing its
configuration paths; an order may be paid before testing shipment and refund; a workflow
may be approved before testing its downstream outcomes. The running example throughout is
an order lifecycle — chosen only because its branching states are easy to see, and standing
in for whatever lifecycle your own database implements. To test both placed → paid → shipped and
placed → paid → refunded, a conventional suite constructs placed → paid twice. This
article develops the alternative: express the scenarios as a directory tree and walk it
with PostgreSQL savepoints — execute the shared prefix once, test one branch, roll back to
the branch point, and test its sibling from the same inherited state. Every scenario then
runs against the accumulated state it actually depends on, without rebuilding that state
and without seeing a sibling’s changes. A lifecycle’s reachable histories branch like a
multiverse, far beyond what a practical suite can cover, so the tree is authored, not
exhaustive: you choose the critical paths, and the walk proves each one from the exact
parent state it depends on — proof here meaning execution plus declared-invariant checks,
not formal verification. And because most PostgreSQL DDL is transactional, the whole walk can run inside a
still-uncommitted deployment: apply the migration, run the tree, discard the test state,
and commit only if every authored scenario passes.
The argument runs in five parts: the scenario-tree pattern itself; what bounded, authored proof buys over exhaustive histories; the walk as the deployment’s commit gate; a live run — a green walk, a failing branch, a synthetic stress run; and the costs and limits. Two appendices carry the lineage and the PostgreSQL subtransaction internals.
But the destination fits in one screen, so here it is before the argument — the sections below build every piece this sketch uses:
__test__/ -- the scenario tree: every directory is a business state
_setup.sql -- root state: a product in stock
order_placed/ -- its _setup.sql places the order
test_placed.sql -- tests assert against the state they sit in
cancelled/ -- branch: cancel it; assert the stock is restored
paid/ -- sibling branch: pay it — never sees the cancellation
shipped/
delivered/ -- deepest scenario: placed → paid → shipped → delivered
refunded/The runner walks the tree depth-first, with a savepoint per directory plus one the tests
roll back to. After a branch finishes, ROLLBACK TO the savepoint taken before that
branch’s transition restores the shared parent state. Run that walk inside the deployment
transaction, and the tree decides whether the migration commits.
The pattern#
The pattern applies to PostgreSQL-backed state machines whose relevant transitions can run inside one transactional session; an order lifecycle makes the mechanics easy to see.
An order is placed. From that moment the lifecycle branches: the customer pays, or
cancels. If they paid, the order ships, or the payment is refunded. Each branch has its own
invariants — conditions that must hold in that state: after cancellation the reserved stock
must be back on the shelf; refunds must
never total more than the captured payment; a retried payment message must post once, not
twice. Testing pay(), cancel(), and refund() individually is not enough. Correctness
also depends on which state they run from and which transitions came before them.
Database tests are commonly organized as straight lines: build state, walk one path, assert,
discard. Scenarios that share a long history — placed → paid → shipped and
placed → paid → refunded — each rebuild placed → paid unless the harness preserves it
transactionally. That repeated prefix is the shared history; as it grows, setup cost grows
with it, making deep scenarios progressively more expensive to test and pressuring suites
toward shallower ones.
Savepoints are the way out. Note what the savepoint names mean: a savepoint marks the state before a transition — the branch point you return to — not the state you are about to test.
BEGIN;
INSERT INTO product (sku, stock) VALUES ('WIDGET', 5);
SELECT place_order('WIDGET', 2); -- shared history: the order exists
SAVEPOINT before_cancel;
SELECT cancel_order(...); -- transition into the cancelled state
-- assertions that hold after cancellation
ROLLBACK TO SAVEPOINT before_cancel; -- back to the branch point
SAVEPOINT before_pay;
SELECT pay_order(...); -- the sibling transition
-- assertions that hold after payment
ROLLBACK TO SAVEPOINT before_pay;
ROLLBACK; -- or COMMIT — the deployment gate below turns on this choiceThe placement ran once. Both branches inherited it. Neither saw the other’s transactional changes.
The toy SQL shows only the rollback semantics. ROLLBACK TO leaves the named savepoint in
place; a recursive runner also releases each savepoint as it leaves the branch — which is
why live savepoint nesting follows tree depth rather than the number of siblings already
visited.
Maintaining that bookkeeping by hand would obscure the scenario model; a runner can derive it from something engineers already know how to read — a directory tree:
__test__/
_setup.sql -- root state: a product in stock
order_placed/
_setup.sql -- transition: place the order
test_placed.sql
cancelled/
_setup.sql -- transition: cancel it
test_cancelled.sql
paid/
_setup.sql -- transition: pay it
test_paid.sql
refunded/
...
shipped/
...
delivered/
...The directory structure is the scenario tree. Each directory represents a state. The
root _setup.sql establishes the initial state; each non-root _setup.sql applies the
transition into its directory’s state from the inherited parent state. Descendants inherit
the result. Before the next sibling runs, the current branch is rolled
back to its branch point.
Shared setup usually raises a legitimate concern: test contamination. The distinction here
is that siblings never share their changes — only their established history. Both
cancelled/ and paid/ inherit the placed order because both require it; the rollback
makes each sibling’s own transactional changes disappear before the next starts. Sibling tests are
independent; their setup is not.
That is the entire idea. Three terms carry the rest of the article. A state is the
database condition relevant to the lifecycle at one point in its history. A transition
is the business action that moves the lifecycle into its next state; the root _setup.sql
establishes the initial state, and each non-root _setup.sql applies the transition into
its directory’s state. A scenario is one path
through those states. When scenarios share part of that path, they form the scenario tree.
And throughout this article, to prove a scenario means to execute that path and check its
declared invariants — not exhaustive or formal verification.
The walk visits each authored branch, checks its invariants, and unwinds it before the sibling begins. In a standalone test run, an outer rollback erases the test transaction; in the deployment gate below, an outer test savepoint removes the test state while preserving the migration for commit.
Three boundaries before going further:
- Assertions are separate. The tree manages state; inside each node, assert however you
prefer —
DOblocks, pgTAP functions, anything that raises on failure. - The boundary is one transactional session. Concurrency, commit-time behavior, and external side effects need separate tests; the limits section returns to each.
- The tree is executable documentation. Its lifecycle structure is readable by engineers straight off the directory listing and machine-checkable by tooling — checkable for shape (the root establishes its initial state, every non-root directory has its transition, assertions attach to states), not for whether a directory’s name tells the truth about what its transition does; choosing the branches that matter and the invariants that define correctness remains a domain decision.
From exhaustive histories to bounded authored proof#
The pattern above is the entire mechanism. What remains is what it buys — because the case for walking a tree begins with what proving a transactional system would even mean.
What proving a transactional system means#
A database application is a program with memory. That changes what correctness means, and testing theory saw it early. A passage that has stood across editions of The Art of Software Testing singles out exactly this case: in a database application, “the execution of a transaction … is dependent upon what happened in previous transactions. Hence, not only would you have to try all unique valid and invalid transactions, but also all possible sequences of transactions.” That obligation has the shape of the scenario tree: correctness depends not only on individual transactions but on the histories that lead into them. Production state accumulates the way the passage describes — earlier transactions establish state that later transactions read and modify — so reaching any state of interest means the chain of transactions that built it has already run. And at many reached states more than one transition is possible — the placed order is paid or cancelled — each outcome opening further branches. Correctness over the lifecycle depends on that whole branching space: every transition must behave correctly from the accumulated state it actually runs against.
Proving each operation once, from a fresh database, does not establish path correctness; it
establishes the operation in isolation — and the bug may live between operations.
“Cancellation restores the stock the placement reserved” is not a property of
cancel_order; it is a property of the path placed → cancelled.
History-dependent invariants live on paths, not in individual operations — and coverage
inside each operation does not establish coverage of the sequences between them. The gap is not peculiar
to database code: NIST’s
structured-testing guide
illustrates it with two consecutive IF statements — two tests reach 100% statement and
branch coverage while the bug in the statements’ interaction survives untested, a compact
analogue of two consecutive lifecycle transitions, each exercised while their interaction
goes unvisited.
The exhaustive ideal for lifecycle-history coverage is to exercise every reachable history. Real systems add retries, loops, and unbounded data, so that space often needs a bound before it is even finite; once bounded, exhaustive exploration still becomes infeasible quickly. Myers priced the paths of a single twenty-iteration loop at about 10¹⁴ — a billion years at one five-minute test each. Model checking faces the related combinatorial wall known as the state-explosion problem.
Every practical technique that explores such a space has to bound it somewhere. Bounded model checking bounds depth: exploration stops after a chosen number of transitions. Combinatorial testing bounds interaction strength — rather than covering every combination or ordering, it covers interactions only up to a chosen size, justified by the empirical finding that observed failures involve few interacting factors. NIST’s sequence variant is blunt about state: “in many cases, a particular state must be reached before a particular failure can be triggered”; eighty events admit 80! orderings, and forty-two tests cover every three-event ordering.
An authored scenario tree imposes a third kind of bound: authorship. The people who know the lifecycle choose the branches that matter — not all of them, the critical ones. The walk then proves each from the exact parent state it depends on. That is the precise claim this method makes — bounded and authored — and the limits section returns to what it deliberately does not claim.
Different transaction bugs need different tests#
Database testing covers several failure classes, and they need different techniques. Scenario trees target history-dependent bugs: failures that appear only after a particular sequence of prior transitions has established the state in which the next transition runs. They do not replace concurrency testing, which needs multiple sessions, nor tests of commit-time behavior. The stakes are real, and the failures do not announce themselves: one study verified 22 exploitable transaction-logic defects across twelve widely deployed eCommerce platforms, while another — studying transaction bugs in database engines rather than application logic — found that they overwhelmingly fail silently, leaving plausible but wrong state with no crash. Appendix A carries both studies. A strategy that exercises histories must therefore assert the resulting state, not merely observe that each transaction completed.
Why suites walk straight lines instead#
The practical obstacle is usually not writing the assertion — it is reconstructing enough history to reach the state worth asserting. Fernando Borretti’s Composable SQL (2025) describes the experience — populating the foreign-key graph recursively until “testing the simplest query requires building up a massive object graph, just to test an infinitesimal slice of it.” That object graph is the accumulated state, reconstructed by hand for every test.
Mainstream frameworks provide transactional test isolation, but the unit is the individual
test: Django’s TestCase and pgTAP’s runtests() add one shared transaction around
per-test blocks, and no default expresses shared history recursively across nested sibling
scenarios. Appendix A surveys the frameworks and the long record of requests to go deeper.
Copy-on-write database branching attacks the same setup-cost problem at a different layer: it shares committed state across sessions, where savepoints share uncommitted state within one. A Neon or Lakebase database branch appears in constant time regardless of database size and genuinely carries the committed history with it — but it reproduces committed state as a separate database branch, and it cannot fork the uncommitted transaction on the connection already running your scenarios, where a savepoint is a transaction-local marker. The two compose rather than compete: use a database branch for the environment and the committed prefix — one per pull request, one per test run — and walk savepoints over the scenario tree inside it. What stays unreusable without savepoints is precisely the shared history of sibling branches inside one transaction — the common prefix this article is about.
Gate the deployment before it commits#
Once the tree can run inside one transaction, PostgreSQL offers a second advantage: the transaction can contain the deployment itself. Because most DDL is transactional, the entire scenario tree can run inside the deployment transaction: the tree tests the schema and procedures your migration just created, and those schema changes stay uncommitted until every scenario passes.
That test gate — assertions inside the deployment transaction, with COMMIT conditional on
them — is the subject of an earlier article,
Test PostgreSQL migrations before COMMIT
.
What changes here is what is being gated: a scenario tree rather than a flat list of
assertions. The tree is then not a stage in a pipeline that runs near the deploy — it is
the deploy’s own commit condition.
Migration → scenario tests → commit. Operationally: start the deployment transaction, apply
the migration, run the scenario tree, roll the test data back, and commit only if every
scenario passed. One contract makes the gate real: a violated invariant must propagate out
of the tree — or be re-raised after collecting failures — so that it aborts the outer
transaction. Two silent-pass paths break that contract: an EXCEPTION WHEN OTHERS handler
anywhere in the tree can swallow the failure and let the deploy commit green, and an empty
tree — discovery broke, or a filter matched nothing — looks exactly like a passing suite,
so treat zero discovered tests as a hard failure. With the contract intact, candidate
schema and test state disappear together: the deployment is rejected before its schema
could ever commit.
One boundary to state immediately. This is not “run your whole suite inside production
deployments”: migration locks — many ALTER TABLE forms take ACCESS EXCLUSIVE — are held
until commit, so the tree’s runtime adds to the lock window. Deployment-time trees should be small
and scoped to what the deploy touches; big trees belong in CI or against a disposable
database branch.
The composition depends on transactional DDL and on savepoint recovery from ordinary statement errors — PostgreSQL guarantees both; the limits section spells out why.
Against a real target, the walk extends the deployment transaction and with it that lock window. Bound lock waits with transaction-local timeouts, keep the deployment on a connection that preserves session state, and give the statements PostgreSQL refuses inside transaction blocks their own idempotent phase after the commit — the limits section carries the full operational treatment.
Finally, persist the pass/fail record outside the transaction. PostgreSQL messages sent to the client are not rolled back with database state, so the deployment runner can capture them in its durable log even when the deployment transaction aborts — the one escape from the transaction that works in your favor.
Running it#
Nothing here requires pgmi: any runner that walks the tree depth-first and brackets each
branch with SAVEPOINT / ROLLBACK TO implements the pattern. A runner needs four
conventions: which file establishes a directory’s state (_setup.sql here — initial state
at the root, transition elsewhere), which files are assertions
(the directory’s other .sql files), the order within a directory (transition, then
assertions, then children), and the sibling order (name order here).
pgmi
, a deployment tool I build, is the implementation
used below. pgmi calls the _setup.sql files fixtures: in the scenario model, the root
fixture establishes the initial state and each non-root fixture applies the transition into
its directory’s state. pgmi derives the depth-first walk from the __test__/ tree shown
above. The shape is deliberate — a tree a
deployment can walk, letting the database evolve in every authored direction and proving
each one before committing. The deployment integration is about twenty lines.
pgmi_test() expands into the depth-first savepoint walk; the outer transaction runs the
migrations, executes the scenarios, and commits only if they pass:
BEGIN;
DO $$
DECLARE
v_file RECORD;
BEGIN
FOR v_file IN (
SELECT path, content
FROM pg_temp.pgmi_source_view
WHERE directory = './migrations/' AND is_sql_file
ORDER BY path
)
LOOP
EXECUTE v_file.content;
END LOOP;
END $$;
SAVEPOINT _tests;
CALL pgmi_test(); -- expands into the depth-first savepoint walk
ROLLBACK TO SAVEPOINT _tests;
COMMIT;The loop orders migrations by path for brevity; a project that declares sort keys reads
pgmi_plan_view instead, where
execution order is a query result
rather than a filename convention.
Deployed against PostgreSQL 17 in a local container — timings illustrative, transcript abridged in the middle:
[pgmi] Fixture: ./__test__/_setup.sql
[pgmi] Test: ./__test__/test_root.sql
[pgmi] Fixture: ./__test__/order_placed/_setup.sql
[pgmi] Test: ./__test__/order_placed/test_placed.sql
[pgmi] Fixture: ./__test__/order_placed/cancelled/_setup.sql
[pgmi] Test: ./__test__/order_placed/cancelled/test_cancelled.sql
[pgmi] Fixture: ./__test__/order_placed/paid/_setup.sql
[pgmi] Test: ./__test__/order_placed/paid/test_paid.sql
...
[pgmi] Test suite completed (24 steps)
committed state: 0 product rows, 0 order rows
✓ demo2: 19 files loaded, 1 test macro(s) expanded in 0.34sSeven states across five levels of nesting, twenty-four steps, 0.34 seconds — and after
COMMIT, the deployed schema remains while the test-created product and order rows are
gone. Branch-level rollbacks removed each branch’s changes as the walk returned, and the
final ROLLBACK TO SAVEPOINT _tests removed the remaining transactional test data before
the outer commit. The deployment committed only after every authored branch had passed
against the candidate schema, and the test-created rows stayed invisible to other sessions
throughout. The transaction itself is still observable — its locks can block other
sessions — and some effects survive the rollback: sequence advances are not undone, and
anything that leaves the transaction can be observed outside it.
The failing run demonstrates what the deployment gate changes. Introduce a defect into one
branch’s implementation — make cancel_order forget to restore the reserved stock — and
deploy to a fresh database:
[pgmi] Fixture: ./__test__/order_placed/cancelled/_setup.sql
[pgmi] Test: ./__test__/order_placed/cancelled/test_cancelled.sql
✗ broken2: failed after 0.53s
pgmi: error: execution failed: ERROR: Failed in ./__test__/order_placed/cancelled/test_cancelled.sql: cancel must restore stock to 5, got 3 (SQLSTATE P0001)Exit code 13. The database afterward contains no tables at all. The migration had executed; the walk reached the scenario whose invariant the implementation violated; the exception aborted the transaction; schema and test states vanished together. When deployment is gated this way, a migration that violates a tested invariant never becomes the database’s schema — for everything inside the gated transaction; a separate concurrent phase stays outside the gate.
The branch savepoints exist to explore successful alternative histories, not to swallow
failed assertions: the runner issues ROLLBACK TO only after a step succeeds, so an
unhandled exception propagates out of the whole script and aborts the transaction.
Make the tree tell the truth#
Three conventions make trees like this hold up in practice.
Prefer real transitions over hand-built state. Below the root, a _setup.sql should
normally move the inherited parent state into this directory’s state by calling the same
transactional operations the application calls — direct row manipulation is the exception,
not the default. (Reference data inverts this: a catalog with no lifecycle is rightly
declared as desired state
rather than reached through transitions — here the lifecycle is the thing under test.)
That discipline is what makes the directory tree a truthful map: fabricated rows
can create a state the application could never reach through its real transitions, and a
test against such a state proves the operation, not the lifecycle path the tree claims to
represent.
Expose the identities transitions create, so tests and child transitions never have to rediscover or guess them:
-- order_placed/_setup.sql
CREATE TEMP TABLE world_order AS
SELECT place_order('WIDGET', 2) AS id;Assert relative to the inherited test state. This demo can assert against database-wide counts because it deploys into an empty database. A real target already contains unrelated state: the root fixture should introduce isolated test entities rather than assume an empty database, descendant transitions should mutate those entities through the normal application operations, and assertions must be scoped to those entities rather than to global counts.
How the demo checks sibling isolation#
One test in the demo is a runner test, not an application-testing pattern: it
deliberately exploits the demo’s deterministic sibling order — cancelled/ executes first,
so paid/ can detect any leaked state. Application assertions should not depend on sibling
order.
-- paid/test_paid.sql (abridged)
DO $$
DECLARE
v_status text;
BEGIN
-- cancelled/ sorts before paid/, so that sibling already ran and was
-- rolled back. If its rollback leaked, this fails.
SELECT o.status INTO STRICT v_status
FROM sales_order o
JOIN world_order w ON w.id = o.id;
IF v_status <> 'paid' THEN
RAISE EXCEPTION 'expected status paid, got %', v_status;
END IF;
END $$;Demo note. sales_order avoids the reserved word ORDER; the tests use
RAISE EXCEPTION rather than ASSERT because PostgreSQL can disable PL/pgSQL assertions
with plpgsql.check_asserts = off.
Costs and limits#
A depth-first walk keeps live savepoint nesting proportional to tree depth, not total tree width: once a branch finishes, it rolls back before its sibling begins. Total subtransaction work still grows with the number of writing branches. In a synthetic stress run, a generated tree of 121 states — depth five, fanout three, every node asserting its complete ancestor lineage and the absence of any non-ancestor state — ran its 363 steps in 0.75 seconds on the same container. Restated as straight lines — every scenario rebuilding its path from the root — the same 121 scenarios execute 547 transitions where the walk executes 121, at the same step count. The wall clock follows the transition count: with transitions that only record an event, the straight lines land within noise of the walk (0.88 s against 0.75 s, medians of five runs); with each transition also writing a thousand rows of working data, they take 2.9 s against the walk’s 1.2 s. What sharing the prefix saves is proportional to what a transition costs. Wide write-heavy trees still consume transaction IDs and can create WAL and replica-side costs; Appendix B explains the PostgreSQL internals, including the 64-subtransaction cache threshold.
The boundaries:
- One session, one timeline. A scenario tree cannot test interleavings between sessions — concurrency needs concurrent harnesses. (Serializable isolation narrows what concurrency can break, but deadlocks, lock contention, and retry behavior still need concurrent tests — a gap that stays empty by default: one production codebase I reviewed carried a hundred-plus SQL test files and zero concurrency tests . Run the walk itself at READ COMMITTED unless isolation behavior is itself under test; Appendix B explains the savepoint and predicate-lock interaction.)
- One transaction, one boundary. The tree runs each path inside one transaction by construction, so it cannot tell you whether production draws its transaction boundaries in the same places — an operation split across two production transactions still passes when the tree wraps it in one. Wrong-boundary defects are the largest class ACIDRain found, and they need their own review. (The gap closes by construction when the boundary is a declaration the operation itself carries and the database enforces — the argument of The Request Becomes a Transaction .)
- One transaction, one clock.
now()andtransaction_timestamp()return the same instant at every node of the walk, so a rule like “the refund window closes thirty days after payment” cannot be exercised by letting real time pass between nodes. Inject the business date as a parameter each transition reads, rather than callingnow(). - Commit-time behavior needs its own phase.
NOTIFYdelivery and post-commit hooks never fire inside the tree. Deferred constraints are partly testable inside it —SET CONSTRAINTS ALL IMMEDIATEforces the check at a point you choose, with the violation surfacing at a savepoint you can roll back — but statements that refuse transaction blocks (CREATE INDEX CONCURRENTLY,VACUUM,CREATE DATABASE) are unavailable here too. - Not everything rolls back. Sequence advances survive by design — one more reason a
walk keys its rows by UUID rather than by a sequence value; session-level advisory
locks survive; anything that leaves the transaction —
COPY TO PROGRAM,dblink, log output — escapes entirely. And rollback is logical, not physical: rolled-back test rows were still written to the heap and the WAL, and their traces can reach replicas and physical backups until vacuum and backup retention age them out — use synthetic fixture data, never copies of real records. A transition whose real implementation is an external effect — issuing a certificate, calling a broker — needs decomposing into a transactional core the tree can exercise and an effect dispatch it cannot. - The tree is a tree. An acyclic lifecycle maps onto directories cleanly; a state reachable from several parents, or a cycle back into an earlier state, must be authored once per path that reaches it — the walk reuses history up to each divergence, but it cannot merge branches back together.
- The walk is serial. Suite parallelism is unavailable inside one tree; the payback comes from reusing shared history, not from parallel workers. Parallelism returns one level up — trees in separate database branches or databases run concurrently. Re-running one failing leaf means re-walking its ancestor path, and a broken transition fails its whole subtree.
- Bounded, authored testing — not model checking. The claim is never “all scenarios were tested”; it is “every authored branch was exercised from the parent state it depends on, without rebuilding that parent history.” Savepoints change the economics of the scenarios you author; they do not discover the ones you missed.
Running the gate against a real target#
The gate’s lock window is a budget: use SET LOCAL lock_timeout inside the gated
transaction to bound how long each statement waits to acquire its locks — SET LOCAL,
because a session-level setting leaks into the later concurrent-index phase, where a tight
bound aborts CREATE INDEX CONCURRENTLY and leaves an invalid index behind. A waiting
ACCESS EXCLUSIVE request also
turns every later conflicting request into a blocked one
,
the long transaction pins the vacuum horizon while it runs, and on PostgreSQL 17+
transaction_timeout can cap the whole gated transaction.
The session-scoped state — temp tables, settings — must outlive individual transactions, so
connect directly, or through a session-level pooler, rather than a transaction-mode one.
Statements PostgreSQL refuses inside transaction blocks — CREATE INDEX CONCURRENTLY among
them — cannot participate in the gated transaction and belong in the deployment’s
psql-mode tail
;
they remain outside this proof, and each tail statement autocommits on its own, so a
mid-tail failure leaves earlier ones applied — write the tail so a corrected re-run
converges. (pgmi’s
lock-safe-deploy example
collects the full operational treatment: per-phase timeouts, invalid-index recovery,
idempotent tails.)
Why PostgreSQL#
The composition leans on two PostgreSQL guarantees. Most DDL is transactional, so a
migration can stay uncommitted while the tree runs against it. And ordinary statement
errors are recoverable through savepoints: ROLLBACK TO is documented as
“the only way to
regain control of a transaction block that was put in aborted state by the system due to an
error” — the walk can always return to a branch point with the outer deployment transaction
alive. Serialization failures are the caveat, and SERIALIZABLE is not the line: already
at REPEATABLE READ a concurrent update can fail the walk with an error whose documented
remedy is retrying the whole transaction, and under SERIALIZABLE a serialization failure
dooms the whole transaction, savepoints included. Run the walk at READ COMMITTED unless
isolation behavior is itself under test.
Other engines realize the pattern only up to a point. The fail-fast walk shown above ports
to SQL Server directly — a failing branch aborts the whole deployment either way. What does
not transfer is recovering a failed branch and continuing: SQL Server documents an
uncommittable transaction — reached under SET XACT_ABORT ON or a sufficiently severe
error — in which “the session can’t commit the
transaction or roll back to a savepoint”. Oracle’s implicit DDL commits rule out the
deployment gate entirely, though utPLSQL demonstrates the savepoint-isolated test walk
itself. Savepoints are standard SQL; the guarantees this composition needs are not.
Takeaway#
A transactional database evolves through branching histories, and scenario tests should preserve that shape: let the state grow into every authored future, prove each from the exact history it depends on, unwind each branch to its shared prefix, and erase the remaining test state before commit. Savepoints make that walk affordable — a test returns to a shared branch point instead of rebuilding the path to it — and PostgreSQL’s transactional DDL lets the walk gate the deployment that ships it.
Start small. Pick one lifecycle your database already implements — an order, a device, a
job, an approval, a subscription, a claim — with three or four meaningful states. Do not redesign your testing stack. Represent just that lifecycle as
directories, put each transition in a _setup.sql, and see how much repeated
shared-prefix setup disappears. (If you start from pgmi init, the harness is already
wired: the scaffolded deploy.sql gates its commit on CALL pgmi_test(), and the
__test__/ directory follows this convention — the nesting is yours to add.) After that,
adding a branch means describing only what changed: one directory
holding its transition and its assertions — never another retelling of what came before.
Author the branches. Inherit the history. Commit only what every branch survives.
Appendix A: evidence and lineage#
The underlying ideas are old; the uncommon part is combining them into the directory-derived scenario tree and deployment gate used here.
Testing theory has known since Tsun Chow’s 1978 paper on finite-state machines that a test suite for a stateful system is naturally a tree — his method builds a “testing tree,” and he notes that “many sequences have common prefixes and thus could be verified in groups.” What the classical work lacked was a cheap way back to a shared prefix; its only return path was a full reset.
Databases have had that cheap return for as long as they have had savepoints. Jim Gray’s 1978 notes describe the savepoint as “a firewall that allows a transaction to stop short of total backup” and already use the word backtrack; Eliot Moss’s 1981 nested-transaction model made the branching explicit — on a child’s failure, “a parent might retry a failed child, or try to accomplish the same end in another way.”
Depth-first exploration over savepoints has direct precedent. A TACAS 2013 model checker restored database state on backtrack via savepoints, noting the approach “works only for depth-first search exploration because there can be at most one sequence of savepoints in a database”; Microsoft holds a patent on stacked-savepoint test hierarchies (US 9,501,386, filed 2014 — after the TACAS work above was published — granted 2016); utPLSQL v3 ships nested savepoint-isolated test contexts for Oracle.
The web-application strand of the lineage is Evan Miller’s 2010 essay Functional Tests as a Tree of Continuations . It describes the problem exactly — when sibling paths keep replaying an ever-longer shared prefix, total setup work grows quadratically with path length — and the remedy: “automatically make a copy of the universe at the end of Step 1, then run child tests on each parallel universe.” The image fits the mechanism exactly: the tree is a multiverse of the lifecycle — sibling branches are alternative futures of one shared history, and along each scenario the database evolves from an early beginning toward one target state, the tree holding every authored direction of that evolution in one structure. A commenter on the essay’s Hacker News thread named savepoints as the mechanism the same week, blocked only by their ORM. When the essay resurfaced on Hacker News in 2025, the thread held people still wishing it existed. David Wheeler had asked the larger question at PGCon 2009 , introducing pgTAP: “why is it that we don’t write database unit tests?” Sixteen years of naming the gap, from that question to that thread. The branching idea was already present; what remained uncommon was packaging it into everyday database-test tooling.
The framework record says the one-level ceiling is felt. Django’s TestCase wraps the
class in one transaction with a nested per-test block; pgTAP’s runtests() runs each test
as a subtransaction inside one transaction; Rails wraps tests in rollback transactions by
default, and Spring’s transactional test support similarly rolls back each participating
test. Testcontainers’ own guidance recommends sharing one database container across the
suite, and its Spring Boot guide resets application data with deleteAll() before each
test; PostgreSQL 18’s file_copy_method = CLONE lets template copies share disk blocks on
capable filesystems — ever-cheaper environments, the same straight-line state strategy. The
tickets asking to nest deeper,
like pgTAP’s #96
, have been open for years. A
Ruby gem, rspec_nested_transactions
,
has wrapped nested RSpec contexts in savepoints since 2013; beyond that, I could not find a
commonly used PostgreSQL framework that turns nested scenario structure into a savepoint tree.
The cost side has its own evidence. Gerard Meszaros’s xUnit Test Patterns put tests against a real database at roughly fifty times the cost of in-memory tests, and the bill shows up at scale: Shopify found that even after deleting tests, compute fell only about a quarter, “because a significant chunk of computing time is still used for setting up containers, databases, and pulling caches”; an early Discourse attempt to share pre-built state was abandoned because “a significant number of the tests assume they are starting with a blank slate.”
The stakes side has its own studies. The ACIDRain study (SIGMOD 2017) verified 22 exploitable defects across twelve widely deployed eCommerce platforms — transaction boundaries drawn wrong, so an invariant holds within each statement but not across the whole operation — and 17 of the 22 survive even SERIALIZABLE isolation: the fix has to be in the logic, and the logic needs tests. ACIDRain’s exploits are themselves concurrency-driven, which is why concurrency keeps its own harness. A 2024 study of 140 confirmed transaction bugs in six database engines themselves found that 76.4% fail silently — wrong data, wrong query results, no crash; almost all needed no more than three concurrently interleaved transactions over tables of a few rows, and 94.3% triggered deterministically under a fixed multi-session schedule. Transaction bugs can leave plausible but wrong state without producing a crash — the failure mode behind the main text’s rule that history-exercising tests must assert the resulting state.
One story from a different bug class — concurrency — is worth keeping for what it says about method. When Jepsen tested PostgreSQL 12.3 in 2020, a generative checker surfaced in a two-minute run a serializability defect that nine years of hand-picked example transactions had passed over. The moral is about method: systematic exploration can find failures that hand-picked examples miss. A scenario tree does not provide that generation; it provides a structured execution model for a bounded, authored subset of the histories a single session can reach.
What I could not find, after specifically searching for earlier implementations, is an existing tool combining two properties this article leans on: the scenario tree derived from the directory structure itself, and the walk running inside a still-uncommitted deployment. If one exists, I want to hear about it.
Appendix B: the subtransaction internals#
If you only care about using the pattern, you do not need this appendix; it explains why a depth-first walk avoids accumulating live subtransactions across sibling branches, and which subtransaction costs remain.
Why depth-first traversal bounds live subtransactions#
The 64-subtransaction cache is not a 64-node or 64-sibling tree limit: what matters is how
many non-aborted subtransaction XIDs are live at the same time.
PostgreSQL caches up to 64 subtransaction XIDs per backend (PGPROC_MAX_CACHED_SUBXIDS in
proc.h, PostgreSQL 17); past that, snapshots are marked suboverflowed and visibility
checks fall back to the pg_subtrans SLRU — the cliff behind the “subtransactions
considered harmful” literature, which benchmarks savepoints held open (Laurenz Albe measured
a 60% throughput drop moving from sixty to ninety open writing savepoints). The cache counts
non-aborted subtransactions: RecordTransactionAbort removes an aborted child’s XIDs
immediately, which is why the number of live cached subxids in a walk whose branches all end
in ROLLBACK TO is bounded primarily by depth. pgmi’s emitted walk also releases each
directory’s savepoint as it leaves the branch, so nothing accumulates across siblings;
measured on PostgreSQL 17, two hundred rolled-back writing subtransactions left the cache at
zero, unoverflowed.
Costs rollback does not erase#
Three residuals. Every writing subtransaction consumes a real XID whether or not it rolls
back (500 rolled-back subtransactions advance the counter by 501 — vacuum-freeze pressure at
scale). Once a transaction does exceed 64 live subxids the overflow flag is sticky for its
remaining lifetime (cleared only at transaction end — ProcArrayEndTransactionInternal).
And whenever wal_level is replica or higher — the default — PostgreSQL emits a WAL
assignment record for every 64 assigned subxids, and rollback does not retract it; with a
hot standby attached, wide trees inside long transactions can therefore degrade replica
snapshots — the shape behind
GitLab’s replica incident, whose transactions never nested more than ten deep.
Locks and SERIALIZABLE#
A lock acquired after a savepoint “is released immediately if the savepoint is rolled back
to” (documented in §13.3); on subtransaction commit the source (resowner.c) transfers
them to the parent — Moss’s 1981 rule, abort discards, commit inherits. Predicate locks are
the deliberate exception: under SERIALIZABLE, SSI keys all predicate locking to
the top-level transaction precisely so that “predicate locks must survive a subtransaction
rollback” (README-SSI) — ROLLBACK TO does not undo the reads. A walk cannot conflict with
itself, but its SIREAD footprint grows monotonically, and once the predicate lock table runs short of memory
and page-level predicate locks coarsen into relation-level ones, the documentation warns of
rising serialization-failure rates —
one more reason to run the walk at READ COMMITTED unless serializable behavior is the thing
under test.
All transcripts and measurements in this article were produced on PostgreSQL 17.
Alexey Evlampiev builds data platforms on PostgreSQL. pgmi is MPL-2.0 open source.