Skip to content
Chapter 39Lesson 4

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.

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.

transaction one unit
insert invoice
insert line items
update customer
COMMIT all three persisted
× can’t happen invoice saved · line items missing
ROLLBACK none persisted
A transaction has exactly two outcomes — every statement commits, or every statement rolls back. There is no third branch.

The corrupted invoice from the introduction lives in that impossible middle card; a transaction is what makes the card impossible.

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.

1 / 1

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.

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.

  1. 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.
  2. 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.
  3. 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.
  4. An atomic state transition with side effects in the database. Move an invoice from draft to sent, record that it was sent, and bump lastActivityAt, 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.

A constraint handles it Declarative, always-on, race-free
Needs a transaction A multi-statement check or mutation
No two users can share an email
An invoice total can’t be negative
Every line item points at a real invoice
Allocate the last remaining seat in a plan’s pool
Insert an invoice and its line items together
The sum of allocations must equal the invoice total before saving

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.

Transaction A
Transaction B
time
BEGIN read balance
BEGIN UPDATE balance = 150
COMMIT
read balance same query, again
COMMIT
A sees 100

A starts and reads the balance: 100.

Transaction A
Transaction B
time
BEGIN read balance
BEGIN UPDATE balance = 150
COMMIT
read balance same query, again
COMMIT
A sees 100

B updates the same row to 150 — not committed yet, so A still sees 100.

Transaction A
Transaction B
time
BEGIN read balance
BEGIN UPDATE balance = 150
COMMIT
read balance same query, again
COMMIT
A sees 100

B commits. Its change is now visible to reads that start after this point.

Transaction A
Transaction B
time
BEGIN read balance
BEGIN UPDATE balance = 150
COMMIT
read balance same query, again
COMMIT
A sees 150

A runs the same query in the same transaction — and gets 150. That is a non-repeatable read.

Transaction A
Transaction B
time
BEGIN read balance
BEGIN UPDATE balance = 150
COMMIT
read balance same query, again
COMMIT
A sees 150

read committed allows this: each statement re-snapshots. repeatable read would freeze A's snapshot so the second read still returns 100.

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.

Which isolation level does this write need?

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 committed
repeatable read
serializable
No transaction at all

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.

1 / 1

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.

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”.

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.

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.

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
Schema (Drizzle)
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(),
});
Seed rows (SQL)
INSERT INTO customers (id, name) VALUES (1, 'Globex');

Before reaching for either knob, atomicity or a raised isolation level, ask whether a plain constraint already does the job.