Transactions and isolation levels
Wrapping multi-statement writes in Postgres transactions through Drizzle, choosing an isolation level, and handling the concurrency conflicts they raise.
Picture the write behind a “create invoice” button. It runs three statements: insert the invoice row, insert its line items, then bump the customer’s lastActivityAt.
If the server crashes after the first statement, or the second trips a constraint and throws, you’re left with an invoice that has no line items, a broken row a user will open and report as a bug. Nothing you’ve written so far stops it.
What’s missing is a guarantee that those three statements either all happen or none of them do. That guarantee is a transaction, and it’s the everyday reason you’ll reach for one.
A transaction is a boundary with two independent knobs. Atomicity is the all-or-nothing guarantee; it’s on by default, and it’s why you wrap those three statements. The isolation level governs what a different transaction running at the same instant can see. Treating these two as one decision is how you ship code that’s either fragile or slow, so this lesson takes atomicity in full first, then isolation.
What a transaction is
Section titled “What a transaction is”A transaction is a group of statements with no halfway state: either it commits and every statement’s effect is permanent, or something fails first and the database unwinds every change.
You’ve been using transactions all along. Postgres wraps every single statement in an implicit transaction, so a lone UPDATE across a thousand rows either updates all thousand or, on an error partway, none. Writing an explicit transaction extends that all-or-nothing guarantee across multiple statements, so your three-statement invoice write behaves like one indivisible UPDATE.
Databases advertise ACID , four guarantees worth one quick pass:
- A, Atomicity . All-or-nothing. You’ll reach for this constantly; it’s the first half of the lesson.
- C, Consistency. Every constraint still holds at commit. Your
UNIQUE,CHECK, and foreign keys already do this work. - I, Isolation. Concurrent transactions each see a coherent view instead of each other’s half-finished work. You’ll reach for this rarely and deliberately; it’s the second half.
- D, Durability . Committed writes survive a crash. Postgres handles this; you write no code for it.
Consistency and Durability come free from Postgres and the constraints you’ve declared. The two you control are Atomicity and Isolation.
Picture atomicity as a fork in the road with no middle lane.
The corrupted invoice from the introduction lives in that impossible middle card; a transaction is what makes the card impossible.
Wrapping writes in db.transaction
Section titled “Wrapping writes in db.transaction”In Drizzle, the transaction boundary is a function that takes a callback:
await db.transaction(async (tx) => { // every statement in here uses tx, not db});The shape carries the discipline. Inside the callback you write statements against tx, the transaction handle, never against the outer db. You never call commit or rollback; control flow is the signal. Return normally and Drizzle commits; throw and Drizzle rolls back. Any error, a tripped constraint, a failed validation, a bug, unwinds everything.
To get a value back out, like the new invoice’s id, return it: whatever the callback returns becomes the result of db.transaction(...).
Here’s the invoice-create write, wrapped:
const newInvoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoices) .values({ organizationId, customerId, total }) .returning();
await tx.insert(lineItems).values( items.map((item) => ({ invoiceId: invoice.id, ...item })), );
await tx .update(customers) .set({ lastActivityAt: sql`now()` }) .where(eq(customers.id, customerId));
return invoice;});The callback receives tx, the transaction handle. Everything inside runs against it as one atomic unit; the variable on the left receives whatever the callback returns.
const newInvoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoices) .values({ organizationId, customerId, total }) .returning();
await tx.insert(lineItems).values( items.map((item) => ({ invoiceId: invoice.id, ...item })), );
await tx .update(customers) .set({ lastActivityAt: sql`now()` }) .where(eq(customers.id, customerId));
return invoice;});Insert the invoice and capture the row with .returning(), holding its generated id for the next statement.
const newInvoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoices) .values({ organizationId, customerId, total }) .returning();
await tx.insert(lineItems).values( items.map((item) => ({ invoiceId: invoice.id, ...item })), );
await tx .update(customers) .set({ lastActivityAt: sql`now()` }) .where(eq(customers.id, customerId));
return invoice;});Insert the line items, each pointing at invoice.id. Same tx: if this throws, the invoice insert is undone too.
const newInvoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoices) .values({ organizationId, customerId, total }) .returning();
await tx.insert(lineItems).values( items.map((item) => ({ invoiceId: invoice.id, ...item })), );
await tx .update(customers) .set({ lastActivityAt: sql`now()` }) .where(eq(customers.id, customerId));
return invoice;});Bump the customer’s activity timestamp. Three statements, one boundary.
const newInvoice = await db.transaction(async (tx) => { const [invoice] = await tx .insert(invoices) .values({ organizationId, customerId, total }) .returning();
await tx.insert(lineItems).values( items.map((item) => ({ invoiceId: invoice.id, ...item })), );
await tx .update(customers) .set({ lastActivityAt: sql`now()` }) .where(eq(customers.id, customerId));
return invoice;});Return the invoice; db.transaction(...) resolves to it, carrying the new row out to newInvoice.
Every statement uses tx, and so does every helper
Section titled “Every statement uses tx, and so does every helper”“Use tx, not db” sounds trivial until your transaction calls a helper. In a real codebase you don’t inline every query; you keep small read helpers in db/queries/ like getCustomer(id). The moment one runs inside a transaction, a bug becomes possible.
The db you import is backed by a connection pool . A transaction runs on one connection, checked out for its whole duration. If a helper called from inside reaches for the module-scope db, its query goes out on a different connection, outside your transaction: it commits on its own, can’t roll back with the rest, and can’t see your uncommitted writes, so reading the row you just inserted comes back empty.
Worse, it’s invisible. On a quiet single-developer machine the timing happens to work out, so it passes every test and then corrupts data in production. The fix is a convention the course follows everywhere: any data-layer helper that might run inside a transaction takes the client as its first argument.
const getCustomer = (id: string) => db.query.customers.findFirst({ where: eq(customers.id, id) });
await db.transaction(async (tx) => { const customer = await getCustomer(customerId); // ...this read ran on a different connection, outside tx});getCustomer closes over the module-scope db, so its query goes out on a separate connection: it can’t see this transaction’s uncommitted writes, won’t roll back with it, and nothing warns you.
const getCustomer = (client: DbClient, id: string) => client.query.customers.findFirst({ where: eq(customers.id, id) });
await db.transaction(async (tx) => { const customer = await getCustomer(tx, customerId); // ...same connection, inside tx});The helper uses whatever client it’s handed. Pass tx and the read joins the transaction; pass db elsewhere and it runs standalone. One helper, both call sites, no leak.
That first parameter must accept both the pooled db and a transaction’s tx, since the same helper is called both ways; the alias type DbClient = typeof db | DbTransaction covers both. Once helpers take it, threading the client through stops being something you remember and becomes something the types enforce.
When a write earns a transaction, and when it doesn’t
Section titled “When a write earns a transaction, and when it doesn’t”A transaction isn’t free: it holds a connection and adds a round-trip, so it earns its place only when correctness depends on a group of statements being indivisible. Four situations qualify.
- A multi-row mutation that must all succeed or all fail. An invoice plus its line items; a sign-up that creates a user, a profile, and a first organization row. The work isn’t done until every write is.
- A cross-row invariant the schema can’t express. Say the line-item allocations must sum to the invoice total. You read the rows, compute the sum, and abort if it’s off, all inside the boundary so no concurrent write slips between the check and the commit.
- A read-modify-write under contention. Decrementing a balance, allocating the last seat in a pool, incrementing a usage counter: you read a value, decide, then write a value that depends on what you read. Split the read from the write and two callers can both read the old value.
- An atomic state transition with side effects in the database. Move an invoice from
drafttosent, record that it was sent, and bumplastActivityAt, all of it or none of it.
When not to reach for one matters just as much. A single statement is already atomic, so wrapping it buys nothing, and that includes bulk inserts: db.insert(invoices).values([...]) with an array is one statement however many rows it writes. A read-only query needs a transaction only when it spans several statements that must agree with each other, a snapshot question we reach with the second knob.
Before you write a transaction, ask if a constraint already does the job
Section titled “Before you write a transaction, ask if a constraint already does the job”Look again at case #2. Before you write a transaction whose whole job is to check something, ask whether a database constraint can enforce it instead.
A UNIQUE, CHECK, or foreign key runs on every write automatically, with no race window and no chance you forget to apply it; a transaction-with-a-check is code you must remember to wrap correctly every time. So reach for the transaction only for invariants the schema genuinely can’t express. The test: can a single declarative rule on the table catch every violation on its own? (Postgres also has EXCLUSION constraints for “no two rows may overlap” rules; knowing the name exists is enough for now.)
For each rule, decide whether a database constraint enforces it on its own, or whether it needs a transaction with a check. Drag each item into the bucket it belongs to, then press Check.
Watch for rules that only feel like a transaction-with-a-check. “One primary contact per organization” is enforced declaratively by a partial unique index, the conditional uniqueness you saw earlier in this chapter. Reach for a constraint first.
What another transaction sees: concurrency anomalies
Section titled “What another transaction sees: concurrency anomalies”Your transaction does not run alone. While it’s open, others read and write the same tables, so what does yours see of theirs? If another transaction commits a change to a row you’re reading, do you notice mid-flight, or keep the value from when you started?
That’s the isolation level, the second knob, and the cleanest way to reason about it is snapshots. Each transaction reads from a snapshot of the database. The level decides how stable that snapshot stays over your transaction’s lifetime, and so which changes others commit can leak into your view while you run.
These bugs only appear when two transactions interleave, so one transaction’s code never reveals them. The diagram puts two lanes side by side, Transaction A and Transaction B, time flowing down. Scrub each step and watch the value A observes. Each tab is one anomaly, worsening as you go.
A starts and reads the balance: 100.
B updates the same row to 150 — not committed yet, so A still sees 100.
B commits. Its change is now visible to reads that start after this point.
A runs the same query in the same transaction — and gets 150. That is a non-repeatable read.
read committed allows this: each statement re-snapshots. repeatable read would freeze A's snapshot so the second read still returns 100.
A counts the pending invoices: 3.
B inserts a brand-new pending row — a row that matches A’s filter.
B commits the new row.
A re-runs the same count and gets 4. A row that was never there appeared — a phantom.
read committed allows this. In Postgres, repeatable read already prevents it — A’s snapshot, taken at its first statement, hides B’s later insert.
The rule: at least one admin must remain. A counts and sees 2 admins — safe to demote one.
B runs at the same time, sees the same 2 admins, and also concludes it’s safe.
A demotes admin Ada.
B demotes admin Bo — a different admin.
A commits. One admin left — still fine, on its own.
B commits too. Zero admins remain. Neither transaction read stale data, yet the invariant broke. Only serializable catches this — it detects the two can’t be ordered consistently and aborts one with a serialization failure.
Three names for what you saw. A non-repeatable read is one row giving two answers. A phantom read is a query returning a row that wasn’t there before. Write skew , the subtle one, is two transactions that each read and decide correctly yet together produce a wrong result.
One accuracy note: Postgres’s repeatable read is stronger than the SQL standard requires. Because it uses snapshot isolation, it prevents phantom reads too, not just non-repeatable ones, as tab two shows. The classic claim that only the strongest level stops phantoms describes the standard, not Postgres.
The four isolation levels and where each earns its keep
Section titled “The four isolation levels and where each earns its keep”We’ll name the four from loosest and cheapest to strongest, which is also the order to consider them.
Read uncommitted. The standard’s loosest level, where a transaction can read another’s uncommitted changes. Postgres has no such level: ask for it and you get read committed, so dirty reads can’t happen here. Recognize the name; it’s never the answer in this stack.
Read committed, the default. Every transaction in this lesson has run here. Each statement sees a fresh snapshot of everything committed as it begins. It’s the cheapest level and correct for the vast majority of mutations: you wrap writes for atomicity, and the default isolation is already fine. Because each statement re-snapshots, two reads in one transaction can disagree (a non-repeatable read) and a re-run query can grow (a phantom), the looseness you saw in the diagram.
Repeatable read. Now the whole transaction reads from one snapshot, taken at its first statement. Run the same query five times and you get the same answer five times, whatever commits around you, and in Postgres phantoms are prevented too. Reach for it when a multi-statement read must be internally coherent: a month-end roll-up that must see a dozen tables as of one instant, or any report where two queries disagreeing would be a bug. The cost: if a write in your transaction conflicts with a concurrent write, Postgres can’t honor the frozen snapshot, so it aborts you with a serialization failure and your application must retry.
Serializable. The strongest level. Postgres guarantees the result is as if the concurrent transactions had run one after another in some serial order. It’s the only level that catches write skew, because doing so means reasoning about the combination of transactions, which is what serializability does. It’s the most expensive level and raises serialization failures more often, so the retry isn’t optional; it’s part of the call shape. Reach for it when a cross-row invariant can’t be a constraint and concurrent writers contend over it.
Setting the level in Drizzle is the second argument to transaction:
await db.transaction( async (tx) => { // ... }, { isolationLevel: 'serializable' },);That options object accepts all four level strings, plus accessMode: 'read only' and deferrable; knowing both exist is enough for now.
So which do you pick? The order you ask the questions in matters more than the definitions, and it’s built to resist the instinct to grab the strongest level just to be safe.
A single statement runs all-or-nothing on its own. Wrapping it adds a round-trip and buys nothing.
By far the common case. The statements must commit together; you don’t need a stronger snapshot. Wrap in db.transaction and move on.
Freezes the whole transaction to one snapshot so every read agrees. Be ready to retry on a serialization failure.
Lock the known hot row so writers queue instead of racing. The pessimistic approach, with no retry thrash. Covered in a section below.
The only level that catches write skew. Ship it with the retry, never without.
Most walks land on leaf-none or leaf-default: no transaction, or the default just for atomicity. The higher levels are exceptions you escalate to when a specific anomaly forces your hand, never the starting point.
A nightly job reads from orders, refunds, and payouts to produce one reconciliation report. It writes nothing, but if the three reads don’t reflect the same instant the numbers won’t add up. Which isolation level fits?
read committedrepeatable readserializableread committed re-snapshots per statement, so the three queries could each catch a different committed state. serializable defends against concurrent writers, of which there are none here, so you’d pay for unusable protection. Skipping the transaction leaves three independent statements with room for commits to land between them.Retrying serialization failures
Section titled “Retrying serialization failures”At repeatable read and serializable, Postgres sometimes can’t reconcile concurrent transactions without breaking the guarantee it promised.
Rather than corrupt data, it aborts one transaction and raises SQLSTATE 40001, serialization_failure.
That is Postgres telling you to run it again.
Retry the whole transaction, the entire closure from the top, not just the statement that conflicted. Re-running it must be safe, so the closure must have no side effects outside the database.
Write the retry once as a wrapper, and the call sites never deal with it:
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function isSerializationFailure(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === '40001';}
export async function withRetry<T>( run: (tx: DbTransaction) => Promise<T>,): Promise<T> { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await db.transaction(run, { isolationLevel: 'serializable' }); } catch (error) { if (!isSerializationFailure(error) || attempt === maxAttempts) throw error; await sleep(2 ** attempt * 25); } } throw new Error('unreachable');}A type guard for the one error we retry on. Narrow unknown with instanceof Error, then check the code is 40001, never catch (e: any).
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function isSerializationFailure(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === '40001';}
export async function withRetry<T>( run: (tx: DbTransaction) => Promise<T>,): Promise<T> { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await db.transaction(run, { isolationLevel: 'serializable' }); } catch (error) { if (!isSerializationFailure(error) || attempt === maxAttempts) throw error; await sleep(2 ** attempt * 25); } } throw new Error('unreachable');}A small fixed attempt budget. Because it’s bounded, a permanently-conflicting transaction can’t loop forever.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function isSerializationFailure(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === '40001';}
export async function withRetry<T>( run: (tx: DbTransaction) => Promise<T>,): Promise<T> { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await db.transaction(run, { isolationLevel: 'serializable' }); } catch (error) { if (!isSerializationFailure(error) || attempt === maxAttempts) throw error; await sleep(2 ** attempt * 25); } } throw new Error('unreachable');}Run the closure inside a serializable transaction, returning on the first success.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function isSerializationFailure(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === '40001';}
export async function withRetry<T>( run: (tx: DbTransaction) => Promise<T>,): Promise<T> { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await db.transaction(run, { isolationLevel: 'serializable' }); } catch (error) { if (!isSerializationFailure(error) || attempt === maxAttempts) throw error; await sleep(2 ** attempt * 25); } } throw new Error('unreachable');}Rethrow on any other error or once the budget is spent. Only the serialization failure is retried.
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function isSerializationFailure(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === '40001';}
export async function withRetry<T>( run: (tx: DbTransaction) => Promise<T>,): Promise<T> { const maxAttempts = 3; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await db.transaction(run, { isolationLevel: 'serializable' }); } catch (error) { if (!isSerializationFailure(error) || attempt === maxAttempts) throw error; await sleep(2 ** attempt * 25); } } throw new Error('unreachable');}Back off before the next attempt with a short, growing delay, which does real work, as the warning below explains.
Mapping unique-constraint conflicts to a clean error
Section titled “Mapping unique-constraint conflicts to a clean error”The next error you catch constantly is the opposite of a serialization failure: same try/catch instinct, completely different handling.
When a write violates a UNIQUE constraint or unique index, Postgres raises SQLSTATE 23505, a unique violation .
Your schema already invites it: a duplicate-email sign-up hits the unique email constraint, and a replayed webhook event id hits the (organizationId, externalId) unique index you built for idempotency earlier in this chapter.
The driver surfaces it as a catchable error carrying code === '23505'.
- Meaning: Postgres couldn’t safely order concurrent transactions.
- Cause: contention at
repeatable read/serializable. - Response: retry the whole transaction.
- Transient; the next attempt usually succeeds.
- Meaning: a value already exists that a
UNIQUErule forbids duplicating. - Cause: a duplicate insert or update, like a duplicate email or a replayed webhook id.
- Response: don’t retry; return a clear message to the user.
- Deterministic; retrying reproduces it exactly.
A small type guard mirrors the one from the retry wrapper, used at the boundary to return a Result instead of letting the error bubble into a 500, the ok / err channel from the course conventions where an expected failure like this belongs.
export async function createUser(input: NewUser) { const [user] = await db.insert(users).values(input).returning(); return user;}Surfaces a duplicate email as a server error. The unique violation throws straight past this function, so the user sees a generic 500 for what is really an ordinary “that email’s taken”.
function isUniqueViolation(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === '23505';}
export async function createUser(input: NewUser) { try { const [user] = await db.insert(users).values(input).returning(); return ok(user); } catch (error) { if (isUniqueViolation(error)) { return err('conflict', 'That email is already taken'); } throw error; }}Turns the conflict into a typed result. isUniqueViolation catches 23505 and returns err('conflict', …) for the UI to render on the field; anything else still throws.
Hold on to isUniqueViolation. In a later chapter, when you build forms with Server Actions, this same helper turns a duplicate-email collision into an error attached to the right form field.
Locking a row instead of retrying: SELECT … FOR UPDATE
Section titled “Locking a row instead of retrying: SELECT … FOR UPDATE”When you know the contended row up front, you have an alternative to serializable + retry that is often the better tool: instead of letting both transactions run and aborting the loser, make the second writer wait.
SELECT … FOR UPDATE locks exactly the rows the query returns for the rest of the transaction. Any other transaction that tries to update those rows blocks until yours commits or rolls back, making the read-modify-write safe by serializing conflicting writers:
await db.transaction(async (tx) => { const [account] = await tx .select() .from(accounts) .where(eq(accounts.id, accountId)) .for('update');
await tx .update(accounts) .set({ balance: account.balance - amount }) .where(eq(accounts.id, accountId));});The .for('update') runs inside an ordinary read committed transaction, no higher isolation level needed, because the row lock does the serialization the higher level would, scoped to just the rows you named.
These are two approaches to concurrency, and which fits is a judgment call about how often conflicts happen:
- Optimistic concurrency (
serializable+ retry): let everyone run, catch conflicts at commit, retry the losers. Use it when conflicts are rare, so the retry almost never fires. - Pessimistic concurrency (
FOR UPDATE): take the lock first and make conflicts wait. Use it when contention is high on a row you can name up front, like one balance or one inventory count.
Keeping transactions short: the pool-starvation rule
Section titled “Keeping transactions short: the pool-starvation rule”The “keep it short” rule has a mechanism behind it, and a bug that only shows up under load.
The default db client talks to Postgres through PgBouncer in transaction-mode pooling : a real connection is checked out for the entire duration of your transaction and returned only on commit or rollback. A single statement borrows one for milliseconds; a transaction holds it until it closes.
Now do something slow inside that boundary, like calling an external API, sending an email, or charging a card, and you hold a pooled connection for the length of that network call. One such transaction is survivable. Run dozens at once under real traffic and the pool runs dry: every other request, even one reading a single row, queues up behind a handful of transactions sitting idle on an HTTP response. That is pool starvation .
This is the same discipline as the retry rule: a transaction must be safe to re-run, and one that emailed or charged a card mid-flight would send a second email or double-charge on retry.
await db.transaction(async (tx) => { const [invoice] = await tx.insert(invoices).values(input).returning(); await sendEmail(invoice.customerEmail, invoice); await tx .update(customers) .set({ lastActivityAt: sql\`now()\` }) .where(eq(customers.id, input.customerId));});Holds a pooled connection through a network call. The email send sits inside the boundary, so the connection stays checked out for the whole round-trip to the provider. Under load these stack up, the pool starves, and unrelated reads start timing out.
const invoice = await db.transaction(async (tx) => { const [created] = await tx.insert(invoices).values(input).returning(); await tx .update(customers) .set({ lastActivityAt: sql\`now()\` }) .where(eq(customers.id, input.customerId)); return created;});
await sendEmail(invoice.customerEmail, invoice);Commits fast, then sends. The transaction does only database work and returns in milliseconds, freeing the connection. The email goes out after commit, which also leaves the transaction safe to retry.
Some transactions, like a data migration or a rare batch job, genuinely can’t finish in milliseconds; those use the unpooled connection (dbUnpooled) so they don’t tie up a connection request traffic needs. Everything on the request path uses pooled db and stays well under a tenth of a second.
Worked example: atomic invoice create, proven by rollback
Section titled “Worked example: atomic invoice create, proven by rollback”Back to the write this lesson opened with, at the data-layer level: just the db.transaction shape. Wrapping it in a Server Action, parsing input, and revalidating come in a later chapter.
Here is the hazard made concrete: three bare statements with nothing tying them together.
export async function createInvoice(input: NewInvoiceInput) { const [invoice] = await db .insert(invoices) .values({ organizationId: input.organizationId, customerId: input.customerId, total: input.total, }) .returning();
await db.insert(lineItems).values( input.items.map((item) => ({ invoiceId: invoice.id, ...item })), );
await db .update(customers) .set({ lastActivityAt: sql\`now()\` }) .where(eq(customers.id, input.customerId));
return invoice;}No boundary, so a crash between statements corrupts data. Each db call commits on its own, so if the line-items insert throws, the invoice row is already committed with no lines.
export async function createInvoice(input: NewInvoiceInput) { return db.transaction(async (tx) => { const [invoice] = await tx .insert(invoices) .values({ organizationId: input.organizationId, customerId: input.customerId, total: input.total, }) .returning();
await tx.insert(lineItems).values( input.items.map((item) => ({ invoiceId: invoice.id, ...item })), );
await tx .update(customers) .set({ lastActivityAt: sql\`now()\` }) .where(eq(customers.id, input.customerId));
return invoice; });}One boundary, all three or none. The same statements run on tx inside db.transaction: a throw anywhere rolls back the group, and the returned invoice crosses the boundary out. The default level, read committed, supplies the atomicity, which is all this write needs.
To see the rollback work, throw inside the boundary after the invoice insert, then check that nothing survived.
import { expect, test } from 'vitest';
test('createInvoice rolls back when a later statement fails', async () => { await expect( db.transaction(async (tx) => { await tx.insert(invoices).values(validInvoice).returning(); throw new Error('boom — simulate a failure after the insert'); }), ).rejects.toThrow('boom');
const rows = await db.select().from(invoices); expect(rows).toHaveLength(0);});The insert ran, the throw aborted the transaction, and the row count is zero: the corrupted invoice from the introduction, prevented.
The exercise gives you a seeded schema and a starter that does the three writes unwrapped, the “before” shape exactly. Wrap them in db.transaction, run every statement on tx, and return the new invoice so the grader can read it back.
The three writes below run unwrapped, so a crash between them would leave a corrupted invoice. Wrap all three in db.transaction, run every statement on tx instead of db, and return the inserted invoice so it crosses the boundary back out.
View schema & seed rows
export const customers = pgTable('customers', {
id: integer('id').primaryKey(),
name: text('name').notNull(),
last_activity_at: timestamp('last_activity_at'),
});
export const invoices = pgTable('invoices', {
id: integer('id').primaryKey(),
customer_id: integer('customer_id').references(() => customers.id),
total: integer('total').notNull(),
});
export const line_items = pgTable('line_items', {
id: integer('id').primaryKey(),
invoice_id: integer('invoice_id').references(() => invoices.id),
description: text('description').notNull(),
amount: integer('amount').notNull(),
}); INSERT INTO customers (id, name) VALUES (1, 'Globex');
- Query returns the 1 expected row (any order)
Before reaching for either knob, atomicity or a raised isolation level, ask whether a plain constraint already does the job.
External resources
Section titled “External resources”The canonical reference for the four levels and every anomaly, straight from the source.
The db.transaction API, isolation-level options, and nested-transaction behavior.
A Postgres-specific deep dive tying MVCC, snapshots, and each anomaly together with worked SQL.
Martin Kleppmann's runnable test suite showing exactly which anomalies each level really allows.