Build1 publisher3 min readPublished
Ten workers drawing on one account left the balance at 90 and an audit log where every row read 100 and wrote 90. Naming old.balance in RETURNING brings the staircase back; the lost update never needed 18.
The Engineer · Build desk
Compiled by The EngineerSomething wrong?How this is made
Between the SELECT and the UPDATE, nothing holds the row. Each worker reads 100, does the subtraction in application code, and writes 90 back as an absolute value [1][2]. The last write wins and the nine before it are overwritten by a number derived from the same stale read [3]. Then each worker inserts the pair it computed, so ten processes each claim authorship of the transition from 100 to 90 [3].
Add up what the table asserts. Ten rows, each recording a ten-unit decrement, is 100 units of movement; the balance moved 10 [5]. The log overstates the account's activity by 90 units without producing a single artifact a schema check would catch: no gaps, no nulls, ten internally consistent rows [7][5]. The author's point is that the shape of that data reads as a retry storm, which sends the responder off to inspect retry logic that turns out to be correct [7].
Two things change in the Postgres 18 version and only one of them is new. Writing `balance = balance - 10` puts the read and the write in the same statement, which closes the window, and the post is explicit that this has always been available [12]. What 18 adds is `old.balance` in RETURNING: the value this particular UPDATE overwrote, reported by the statement that overwrote it [11][13]. Wrap that UPDATE in a CTE and feed its output into the audit INSERT, and the same ten workers land on zero with ten distinct old values and the staircase intact [14][15]. So an upgrade buys audit fidelity. The lost update you can fix on whatever version you are running by moving the arithmetic into the statement [12].
The numbers are one author's, on one machine, one row, ten workers [2][9]. For them to transfer, your code needs the same shape: a plain SELECT with no FOR UPDATE, arithmetic outside the database, an absolute write [19][1]. The post does not state the isolation level or whether each worker ran in its own transaction [19]. Treat the four-to-six-lost figure as evidence that the window is wide on an idle box rather than as a rate for your system [8][9]. Across the three runs with the sleep removed the final balance averaged 50, which is half the withdrawals applied [10].
Before 18 you could get an honest history from a trigger reading OLD and NEW [16]. That works. It also puts the audit logic somewhere a reader of the application code will never look [16], and that is a maintenance cost, not a correctness one, which is the honest way to price this upgrade.
The other thing worth having is the null-old behaviour on INSERT: `old.id IS NULL` tells you whether an upsert inserted or updated, returning true with a null old balance on the first run and false with 10 and 20 on the second [17]. It replaces `RETURNING (xmax::text::bigint <> 0)` [18]. Anyone who has typed that cast knows why.
If your audit rows are written by application code from values it read a moment earlier, they record what the application believed about the row [1][3]. Postgres 18 lets the statement that changed the row say what it changed [13]; until you get there, a trigger gets you the same history at the price of hiding it [16].
Ranked by verification strength, evidence, and original report placement.
Doing balance - 10 inside the statement means the read and the write happen together so there is no gap for anyone to slip into; the author states this has been possible forever and it is the part that stops the lost update.
The pattern in question is: SELECT balance FROM accounts WHERE id = 1; the application does the arithmetic; UPDATE accounts SET balance = $new WHERE id = 1; INSERT INTO audit_log (account_id, old_balance, new_balance) VALUES (1, $old, $new).
The test used one account starting at 100 and ten workers each withdrawing 10, every worker doing the read-then-write pattern with a 50 ms gap in the middle so the timing landed the same way every run.
With the 50 ms gap, the run ended with final balance 90, 10 audit rows, and 1 distinct old_balance value: the pair 100 | 90 logged ten times, because all ten workers read the same 100 and wrote back the same 90.
The expected outcome was a final balance of zero with the audit log walking down in steps, 100 to 90, then 90 to 80, and so on.
The author notes the audit log agrees with itself: ten neat rows, each internally consistent, no gaps, no nulls, nothing a validator would flag, and says that handed the table during an incident he would conclude the same withdrawal was retried ten times and go read the retry logic, which is fine.
Follow any of these and your For You feed starts watching them — no settings page required.
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
Shown, not asserted — on one machine
The author pastes output rather than describing it: 90 with a single distinct old_balance under a forced 50 ms gap, 50/40/60 with the gap removed, then the full ten-step staircase and a zero balance from the CTE form. Anyone with Docker can re-run all of it, and the version gate on 17 is a hard error rather than a matter of opinion. What is absent is the surrounding configuration — no isolation level, no statement about per-worker transactions, no FOR UPDATE on the read — so the anomaly is demonstrated without being bounded.
One container on a laptop
The entire deployment footprint in this story is postgres:18.6 pulled from Docker Hub, plus a postgres:17 container spun up to confirm the error message. No production system, no managed-service availability, nobody else's numbers on either the failure or the rewrite.
Title claims the fix; body concedes half of it
"Postgres 18 fixes it in one statement" is contradicted inside the post, which says the arithmetic that stops the lost update has been possible forever and that the release only adds the overwritten value. "Probably lying to you" rests on a pattern the author has written himself, not on any look at deployed audit tables. The measured half of the story is undersold rather than oversold: nine of ten withdrawals gone, and a log that passes every check while overstating movement tenfold, is a sharper finding than the syntax it is used to introduce.
Attention, not vendor money
A personal post on dev.to with no sponsor, employer or product in sight; nobody profits if you upgrade. The pull is traffic — a headline written to indict the reader's own audit log, and an explicit bid for search: the author ran the query against 17 so that people arriving on the "missing FROM-clause entry for table old" error land here. That shapes the framing more than it shapes the SQL, which is reproducible either way.
Single account, cheap to falsify
One author, one machine, one publisher, and no independent reproduction anywhere in our coverage — normally enough to hold a number down. Working the other way: these are claims that fail loudly if wrong, since a reader with Docker can contradict the tables in ten minutes and the 18-only behaviour throws an error rather than degrading quietly.
build
A RAG stack lived seven hours before a hosted embedding endpoint returned 4041 publisher
build
The stopping problem: an LLM rewrite loop that converged on code javac rejected1 publisher
build
Eleven agent sessions on one machine settled CPU contention by writing to each other1 publisher
build
The 680 MB database that was really a 17 GB disk: self-hosted support platforms fail at month six1 publisher
Publishers with included, body-backed reporting in this cluster.
1 article · September 6, 2026