In this collection · Transactions and concurrent work

Databases and storage · Working draft

What belongs in one transaction?

Transactions and concurrent work · 1 of 4Relational introductionNext: Concurrent updates →

Many actions that look like one thing in an application require several changes in its database. Moving money between accounts changes two balances. Recording a purchase creates an order and its lines, and may reduce the stock available for sale. The application needs all of those records to describe the completed action.

But the changes do not all happen in one instruction. Some can succeed before a later one fails. A database might accept the stock reduction and then reject an invalid order line. If it kept the earlier change, there would be less stock without a purchase to explain where it went. Asking the application to undo each successful step would leave it with another sequence of operations that could itself fail.

A transaction gives the application a way to group database work. It can commit the changes together when the work succeeds, or roll back the group so none of its changes are kept. This all-or-nothing behaviour is called atomicity. It lets us treat several changes as one completed operation even though the database performs them in steps.

The database cannot decide which changes belong together; that depends on what the application is doing. We’ll use a purchase to work out that boundary, then examine what changes when paying for it involves a separate service. The SQL uses PostgreSQL 18 and the tables and keys introduced in Constraints.

Commit and rollback

BEGIN starts an explicit transaction. The statements that follow belong to it until the application ends it with COMMIT or ROLLBACK. These commands let the application control the boundary explicitly: it starts the work, checks whether each part succeeded, then decides whether to keep or discard the changes.

PostgreSQL also runs an individual statement in a transaction. With autocommit, a successful statement commits on its own. That is convenient for an independent edit, but three separately committed statements give us three separate outcomes. Explicitly grouping them changes what can be abandoned together. A client library may manage transaction boundaries for us, so the application’s actual behaviour needs checking rather than inferring it from the absence of a written BEGIN. PostgreSQL’s BEGIN reference describes these boundaries.

A connection is an application’s active link to the database; the database conversation on that connection is called a session. The transaction can read its own unfinished changes. Other sessions do not read those uncommitted table changes. After commit, a reader using a sufficiently recent view can see the completed purchase. An older view may still show the earlier state; commit does not force every existing reader to refresh. We’ll examine those views in Isolation and snapshots.

Start with the rule

Our shop wants a recorded purchase to reserve exactly the quantities on its order lines. We start a fresh checkout example: the historical orders O12 and O13 remain, their purchases are already accounted for, and O14 does not yet exist. Suppose Ada, customer C4, buys two Blue mugs, product P7. Three mugs are available before this new checkout. Completing this purchase means creating order O14, recording its line for two mugs at €20 each, and reducing available stock to one.

Each partial result tells a different, wrong story. Reduced stock without an order leaves two mugs unavailable with no purchase explaining why. An order and line without reduced stock let the shop promise those same mugs again. An order without its line loses the detail the warehouse needs. The three changes belong in one transaction because the completed state depends on all three.

Illustration · One purchase, two possible endings

Keep all three changes together

Before checkoutP7: 3 available · O14: absent · O14’s lines: absent
One database transaction

Stock

P7 available

3 → 1Two mugs reserved

Order

O14 for C4

New recordThe purchase’s identity

Order line

O14 · line 1

P7 × 2€20 for each mug
COMMIT

Purchase recorded

P7: 1 available

O14 and its line present

ROLLBACK

Purchase abandoned

P7: 3 available

O14 and its line absent

Read the endings as alternatives. Commit keeps the changes made by this transaction. Rollback discards them, including the stock reduction if it happened before a later failure. Neither ending leaves this purchase half-recorded. Other transactions’ work is outside this drawing.

Constraints still have work to do. A foreign key requires the line to refer to an existing order and product. A check requires a positive quantity. Those declarations do not require this application to insert the line before committing an otherwise valid order. The transaction gives us a way to keep a chosen group together; it does not choose the right group or discover an omitted statement.

The stock count below is what remains available for this new purchase; earlier purchases have already been accounted for.

Working through checkout

To run the SQL yourself, start the shared practice setup. It explains opening PostgreSQL, loading the files and choosing a fresh database for this article.

The practice schema from Designing your data supplies customers, products, orders and order lines. Load it into an empty practice database, then add the stock table below. We keep availability separate from the product’s descriptive fields. This example has one stock record per product and one currency, EUR.

Add stock to a fresh copy of the practice schema

Run this setup once in that practice database. Each attempt below starts with three mugs and no order O14; use separate fresh setups when comparing them.

CREATE TABLE stock (
  product_id text PRIMARY KEY REFERENCES products (product_id),
  available integer NOT NULL CHECK (available >= 0)
);
INSERT INTO stock (product_id, available) VALUES ('P7', 3);

The application first begins the transaction and asks to reserve two mugs. The condition available >= 2 requires enough stock; the subtraction uses the row’s value inside the database. RETURNING reports the row that was changed, including its new availability.

BEGIN;
UPDATE stock
SET available = available - 2
WHERE product_id = 'P7' AND available >= 2
RETURNING product_id, available;
Result with the initial three mugs available
product_idavailable
P71

This result is a decision point. Exactly one returned row means this attempt reserved the mugs. Zero rows means it reserved nothing: the stock row was absent or the condition did not hold. Zero rows is a successful SQL execution, so it does not automatically abort the transaction. The application must issue ROLLBACK and stop this checkout branch. UPDATE’s result and RETURNING give the application the evidence for that decision.

Only after receiving the one-row result does the application run the following statements, on the same database connection and inside the transaction it just opened. Creating the order before the line satisfies the line’s foreign key.

INSERT INTO orders (order_id, customer_id, placed_at)
VALUES ('O14', 'C4', '2026-10-04 11:00:00+00');

INSERT INTO order_lines
  (order_id, line_no, product_id, quantity, unit_price)
VALUES ('O14', 1, 'P7', 2, 20.00);

COMMIT;

INSERT INTO adds a record to the named table. The column list and VALUES list correspond in order: for the order, 'O14' fills order_id, 'C4' fills customer_id, and the timestamp fills placed_at. The line insert follows the same pattern. Naming the columns makes that mapping visible instead of relying on their order in the table definition.

After successful commit, one mug remains available and both O14 records exist. There is no separate commit between them. If the application uses a connection pool, it needs a transaction API that keeps this sequence on the same connection. Sending each statement through an arbitrary available connection would not express the transaction shown here.

A failure after the first change

Now consider a separate attempt from the same initial state: three mugs available and no O14. The stock update succeeds, then the order insert succeeds. A bug sends quantity zero for the line, which the schema refuses.

BEGIN;
UPDATE stock SET available = available - 2
WHERE product_id = 'P7' AND available >= 2;

INSERT INTO orders (order_id, customer_id, placed_at)
VALUES ('O14', 'C4', '2026-10-04 11:00:00+00');

-- Deliberate mistake: a line cannot have quantity zero.
INSERT INTO order_lines
  (order_id, line_no, product_id, quantity, unit_price)
VALUES ('O14', 1, 'P7', 0, 20.00);
-- ERROR: the new row violates the quantity CHECK.

-- Issue this after the error, on the same connection:
ROLLBACK;

Run this case against a fresh copy of the setup, and issue the rollback after the error. PostgreSQL puts the explicit transaction into a failed state. The application cannot continue ordinary work as though only that insert had been skipped. Here it rolls back the whole purchase, discarding the order and the stock reduction.

Savepoints can mark a place inside a transaction to return to after an error. They are useful when a portion of the work may legitimately be abandoned. They do not make a required order line optional. Recovering to a savepoint and committing the stock reservation without its purchase would preserve the wrong state. The transaction tutorial explains error recovery, and the SAVEPOINT reference gives the commands.

SELECT available FROM stock WHERE product_id = 'P7';
SELECT count(*) AS orders FROM orders WHERE order_id = 'O14';
SELECT count(*) AS lines FROM order_lines WHERE order_id = 'O14';
These queries after each independent attempt
Attemptavailableorderslines
Successful purchase, committed111
Invalid line, rolled back300

Rollback discards this transaction’s work. It does not mean setting the entire database back to an earlier moment or undoing another customer’s completed order. Nor can a later rollback erase O14 after it has committed. Cancelling a completed purchase is a new change with its own rules: restore the appropriate availability and record that the order was cancelled together. ROLLBACK applies to the current transaction.

When payment happens elsewhere

A payment provider has its own records and decisions. Calling it between BEGIN and COMMIT does not bring those records into this database transaction. If payment succeeds and the line insert then fails, a database rollback does not refund the charge. Reversing the order merely changes the unfinished case: a committed order may be followed by a declined payment.

For this shop, one possible design gives the order an explicit awaiting payment state. A short database transaction reserves stock and records that pending purchase. A separate operation asks the provider for payment. Once its outcome is confirmed, another transaction records payment or cancels the pending purchase and releases stock. The application and warehouse must understand that a pending order is not yet ready to fulfil.

Illustration · Payment crosses a boundary

A purchase can have an honest intermediate state

1 · Database transaction

Reserve the mugs.

Record the order and lines.

Commit: awaiting payment
After local commit

2 · Payment provider

Request payment using a stable reference for this purchase.

Its own operation and outcome
After a confirmed outcome

3a · Payment confirmed

In a new transaction, mark the order paid.

Stock remains reserved

3b · Payment declined

In a new transaction, cancel the pending order and release its stock together.

Stock becomes available again

No confirmed answer? Keep the outcome unresolved and reconcile it with the provider. A timeout does not establish a decline.

This is a proposed workflow, beyond the article’s runnable schema. The provider is outside both database transactions. The second transaction records a later event; it does not roll back the first commit. Processing a repeated payment result must not release stock twice.

This design makes the intermediate state something the application can revisit. It still needs a way to find pending purchases after a restart, identify repeated requests, and deal with payment that remains uncertain. A cancellation handler must change an eligible pending order and release its stock together, and recognise a cancellation already handled. Otherwise a repeated result could add the same mugs back twice. These are requirements of this proposed workflow, not capabilities supplied by adding a status column.

There is also a cost to keeping the transaction open while waiting for payment. Updating the stock record acquires a row lock: another transaction trying to change that same record must wait until this one commits or rolls back. A slow payment call can therefore hold up other purchases of the same product. Holding the lock longer does nothing to settle an uncertain payment. Handling waits, deadlocks, and retries develops that recovery problem; the provider’s own recovery contract must supply the other half.

Reviewing a transaction boundary

A useful way to review this code is to pause after each statement and ask what happens if the next operation fails. Every required change to the purchase should still be inside the transaction at that point. The application should test meaningful results, including a successful update that changed no rows, and commit only once the intended local state is complete.

Prepare work that does not depend on the current database state before opening the transaction, such as parsing the request and checking that its quantity is a positive integer. Availability is different: another purchase can change it. In our example, the conditional UPDATE checks availability as it reserves stock. An earlier read of the count would not give the same permission to buy, whether that read happened before or after BEGIN.

Atomicity answers what happens when this purchase fails partway through. Two purchases can each be complete while jointly promising too much stock. Our conditional update was chosen to address that second problem too. To see why it matters, we need to let another request run between this one’s steps.

Sources and further reading

Working draft, researched 4 October 2026. SQL targets PostgreSQL 18; records and the payment workflow are teaching examples. The runnable purchase examples use ordinary table changes, without triggers or outside calls.