Databases and storage · Working draft
When two requests change the same data
A database usually serves many requests at once. People place orders while staff update products and background jobs process earlier purchases. These requests often work on unrelated records and can make progress independently. Sometimes, though, they need the same information at almost the same time.
That becomes difficult when an application reads something before deciding what to change. The read tells it what was available then. It does not promise that the information will remain unchanged while the application thinks, performs other work, or sends its next command. Another request can act in that gap.
We call requests concurrent when their work overlaps in this way. They need not execute instructions at exactly the same instant. One can pause after a read, let another complete a change, and then carry on with a decision based on the old value.
A transaction keeps each request’s changes together, as the previous article explained. That alone does not settle which request is entitled to make a change. If two buyers both read that one item is available, each can try to record a complete purchase of it. We need a way to coordinate the decision as well as save its results.
We’ll examine that stock problem and compare two ways to handle it: asking the database to check availability as it changes the count, and locking the record before reading it. The examples use PostgreSQL 18 at Read Committed, its default isolation setting. Isolation controls how overlapping transactions interact; the next article looks at the wider choices.
The rule we need to preserve
Product P7 is the shop’s Blue mug. Its stock record has one available item. Requests A and B each want to reserve one mug and record a purchase. Each uses its own database session: a separate conversation with the database on its own connection. A will use order O14; B will use O15. Both can belong to customer C4, Ada: two browser tabs or two separate attempts can overlap even when they come from the same customer. We are treating these as distinct purchase requests, not retries of one operation.
The rule is that each committed purchase must consume one available item. If only one is available and nothing is restocked, at most one of these purchases may commit. The stock count must also stay non-negative, but that is only part of the rule. A wrong program can record two purchases while leaving a perfectly non-negative zero in the stock row.
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.
Set up a fresh practice database for these examples
Load the shop schema and records into an empty database, then run the SQL below once. This adds the stock table used in the previous article, here with one available mug. Each comparison begins from this fresh state, without O14 or O15. Do not rerun this setup over another article’s completed attempt.
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', 1);These new purchases use an agreed €20 unit price. Each attempt must keep its stock change, order and line in the same transaction, as established in What belongs in one transaction?.
Two correct subtractions, one wrong result
Consider application code that reads availability, subtracts one in memory, then sends the new count back. An ordinary SELECT tells the application what it can see. It does not reserve that stock or prevent another transaction from changing it.
-- Each request starts its own transaction.
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT available FROM stock WHERE product_id = 'P7';
-- Each reads 1. Application code computes 1 - 1 = 0.A reads one and computes zero. Before A writes, B also reads one and computes zero. Both now have a plan based on the same mug. The arithmetic is correct in each application; the plans cannot both describe separate successful purchases.
-- Unsafe: 0 was computed from an earlier ordinary read.
UPDATE stock SET available = 0 WHERE product_id = 'P7';
-- The application creates its order and line, then commits.PostgreSQL does coordinate writes to this row. A’s update obtains a row lock: a claim that makes a conflicting writer wait until A’s transaction ends. B’s update cannot simply change the row under A’s unfinished write. But when A commits, B can proceed with the command it already sent: set availability to zero. That command contains no trace of why zero was chosen.
| Step | Request A | Request B |
|---|---|---|
| 1 | Reads 1; computes 0 | — |
| 2 | — | Reads 1; computes 0 |
| 3 | Writes 0; holds the row lock | — |
| 4 | — | Tries to write 0; waits |
| 5 | Creates O14 and its line; commits | Waiting ends |
| 6 | — | Writes 0; creates O15 and its line; commits |
Both orders exist. Stock is zero. The non-negative check passes, and every order line has valid references and quantity one. The database kept the changes of each transaction together and made the writes wait for one another. It was never instructed to reconsider B’s permission to buy.
This is a race: the outcome depends on how the requests’ steps interleave. A test that runs A completely before starting B will miss it, because B then reads zero and declines. A useful concurrency test deliberately places both reads before either write. The schedule above is based on PostgreSQL’s documented behaviour; it is not a claim that these two sessions were run in a benchmark.
Make the test part of the change
For this one-row rule, the application can express the whole stock decision in an update: subtract one only from a row with at least one available. The database receives both the requirement and the change, rather than an answer calculated from a previous read.
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE stock
SET available = available - 1
WHERE product_id = 'P7' AND available >= 1
RETURNING product_id, available;A changes the row from one to zero and holds its lock. If B issues the same update while A is still active, B waits. At Read Committed, after A commits PostgreSQL considers the updated row and checks B’s WHERE condition again. Zero fails available >= 1, so B changes no row. If A rolls back instead, the available mug remains and B can reserve it. This recheck is the crucial Read Committed rule for this example.
| Outcome of A | A’s purchase | B’s UPDATE result | B’s action |
|---|---|---|---|
| Commit | Recorded | No rows | Roll back; no stock reserved |
| Rollback | Absent | P7, available 0 | Create its purchase; commit |
RETURNING contains only rows actually updated. Here the primary key makes at most one row eligible, so one returned row means the reservation succeeded. An empty result is an ordinary outcome, not an SQL error. The application must inspect it. Otherwise it can still create a purchase without obtaining stock. UPDATE documents both its affected-row result and the fact that changing zero rows is not an error.
For request A, a successful reservation is followed by these statements. B uses O15 in both inserts instead. If the reservation returned no row, issue ROLLBACK and skip the inserts. If a later insert fails, roll back the reservation too.
-- Run only if the reservation returned exactly one row.
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', 1, 20.00);
COMMIT;Simply moving the subtraction into SQL is not the whole solution. Without the availability condition, a second subtraction would attempt minus one; our check constraint would refuse it, and the application would need to abandon that transaction. The guarded form makes “no stock reserved” a result the caller can handle directly. The constraint remains a useful defence against other incorrect writes.
Lock before making a longer decision
For our single yes-or-no stock check, the conditional update is enough. A different checkout might allow partial fulfilment: someone requests three mugs, permits a smaller purchase, and only two remain. Application code could inspect the available quantity, choose two, then use that quantity both to reduce stock and to create the order line. It needs the count to remain valid while it makes those related changes.
PostgreSQL offers a locking read for work like this: SELECT … FOR UPDATE retrieves the row while acquiring a lock that excludes competing updates and conflicting locking reads. The application can inspect the returned value while retaining that protection inside its transaction. The partial-purchase decision could also be written in SQL; the locking read gives us a way to make it in application code. It is not a reason to wait for a person or a remote service while holding the lock.
To compare the timing with the conditional update, we’ll keep the original one-mug purchase below. Only the way we read and reserve it changes:
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT available
FROM stock
WHERE product_id = 'P7'
FOR UPDATE;
-- The application now inspects the returned value.
-- Missing row or available < 1: ROLLBACK and stop.
-- Otherwise, while still holding the lock:
UPDATE stock SET available = available - 1
WHERE product_id = 'P7';
-- Create the order and line, then COMMIT.These comments mark application branches, not SQL that performs the branching for you. The program waits for the read to return, checks that the row exists and availability is at least one, then either abandons the transaction or continues. Its update and purchase inserts must use the same transaction and connection. Committing between the read and the update would release the protection.
If A holds the row lock, B’s locking read waits before returning an answer. After A commits its purchase, B receives zero in this Read Committed example. B therefore declines before making its stock change. Acquiring a lock after an ordinary read, then using the old read to decide, would leave the original mistake intact.
Waiting only helps if the decision can change
Requests A and B each want the same one remaining P7 mug. Both would record a purchase and change its available stock from 1 to 0. A reaches the write first; follow what B does while A finishes.
Unsafe: decide from an ordinary read
B reads 1
Plans to write 0B’s write waits for A
A commits stock 0B writes its planned 0
Still creates an orderCoordinated: lock before deciding
B’s locking read waits
A holds the row lockA commits; B reads 0
B now holds the row lockB declines the purchase
No stock, no orderA row lock does not make the row unreadable. Ordinary PostgreSQL reads can still use committed versions while the writer is active. Nor does this stock lock protect every fact the program might consult: a rule involving another stock row or another customer’s balance needs protection that covers those facts too. PostgreSQL’s row-lock documentation describes which operations conflict and how long the locks last.
Both approaches bring competing attempts to the same record before allowing an incompatible change. Their correctness depends on all relevant write paths following a compatible protocol; an administrative script can still defeat the business rule by writing an arbitrary stock value.
Change the first transaction’s ending
Waiting is not itself a rejection. While A is unfinished, B cannot know whether A will keep or abandon its reservation. If A commits, the protected decision sees stock zero. If A rolls back, that same decision can see stock one and proceed. The experiment keeps the request order fixed so that changing the method or A’s ending exposes this difference.
Two requests, one available mug
Requests A and B each want one P7 mug. A reaches the stock row first. Compare an ordinary read followed by an application-computed write, a conditional update, and a locking read. All three put stock and order changes inside transactions.
Advance the unsafe attempt to its end, then change the method. Finally let A roll back: a waiting buyer must get a different answer when the first reservation is abandoned.
Controls
Result Completed unsafe attempt
Request A · O14
A committed one purchase.
Request B · O15
B also committed, using its earlier decision.
Both requests read 1 and decided to write 0. The final stock count is non-negative, but two purchases have claimed the one mug.
What this experiment models
This fixed schedule models two one-mug purchases in PostgreSQL Read Committed. The counters show committed records; the request panels describe unfinished changes. A row lock is held until the owning transaction ends. A stock update and its order commit together. The conditional branch and locking branch create an order only after obtaining stock. This is a documentation-based teaching model, not two live database connections or a performance measurement.
Notice that the committed stock counter stays at one while A’s reduction is pending. That does not grant B permission to buy. The coordinated methods wait for the row they need to change, then obtain a result that accounts for A’s ending. The unsafe method mistakes an earlier observation for that result.
When the rule grows
A cart containing a mug and a bowl can reserve each stock row with a conditional update inside one transaction. If either reservation fails, the application rolls back both. The records being changed have multiplied, but the requirement still decomposes into a per-product availability check plus a transaction that keeps the cart together.
Acquiring several locks introduces a new problem. One transaction might hold the mug row and wait for the bowl; another might hold the bowl and wait for the mug. Choosing a consistent product order for acquiring locks reduces these cycles. Applications still need to handle rejected transactions, which is the subject of Handling waits, deadlocks, and retries.
A different kind of rule cannot be reduced to independent checks. Suppose two bins each contain one display mug, and the shop must leave at least one mug across both bins. Two transactions can each observe a total of two and sell from a different bin. Each local quantity remains non-negative, but together they leave none. Locking only the row each transaction subtracts from does not make them meet at a shared decision.
Both buyers count the same two mugs
At least one mug must remain across the two bins. Each request sells one mug from a different bin, using an ordinary read to check the total first.
Before: left bin 1 + right bin 1 = 2 mugs
| Step | Request A | Request B |
|---|---|---|
| 1 | Reads left 1, right 1. Total 2: decides it can sell one. | Has not read yet. |
| 2 | Has not written yet. | Reads left 1, right 1. Total 2: decides it can sell one. |
| 3 | Locks and changes the left bin: 1 → 0. | Locks and changes the right bin: 1 → 0. |
| 4 | Commits its sale. | Commits its sale. |
After: left bin 0 + right bin 0 = 0 mugs
That wider rule needs a design covering the shared condition: for example, a common reservation record, a locking protocol covering the relevant records, or serializable transactions with whole-transaction retries. Choosing among them requires understanding what a transaction can observe while others run. A stable view and a protected decision are related ideas, but they are not interchangeable.
Sources and further reading
Working draft, researched 4 October 2026. PostgreSQL 18, explicitly at Read Committed. The shop records and schedules are invented teaching examples. The interaction models the documented single-row cases; it does not execute concurrent SQL or model other isolation settings.
- Read Committed — the rules behind the update recheck and the updated row returned by a waiting locking read.
- Explicit locking — conflicting row locks, release at transaction end and deadlocks.
- Returning data from modified rows — how an application obtains evidence of the change it made.