Why Do AI-Made Apps Always Break Here?

Ask any coding model to "write an endpoint to buy a ticket." You'll get this:
async def buy_ticket(ticket_id, user_id):
count = await db.get_stock(ticket_id)
if count > 0:
await db.update_stock(ticket_id, count - 1)
await db.create_order(ticket_id, user_id)
return {"success": True}
return {"success": False}
It reads perfectly. It passes the test you'd write for it. It will sell your last ticket four times on launch day.
What's interesting isn't that the first version is wrong. It's what happens when you fix it. Each fix is locally correct and introduces a new failure one layer down. That staircase — not any single bug — is the actual shape of how AI-assisted code breaks in production.
Here's the full descent.
Layer 0: correct on a single request
The bug is a TOCTOU gap — time of check to time of use. Two requests read count = 1 before either writes. Both pass the if, both decrement, both create an order. Stock lands at -1 if the column allows it, or 0 with two orders against one seat.
The window is a few milliseconds wide. On your laptop, with one browser tab, it never opens. Under a launch spike, it's the only thing that happens.
Fix: make the check and the write one operation.
Layer 1: atomic, and now silently losing money
async def buy_ticket(ticket_id, user_id):
row_data = await db.execute(f"update tickets set stock = stock - 1 where id = {ticket_id} and stock > 0")
if row_data.rowcount > 0:
await db.create_order(ticket_id, user_id)
return {"success": True}
return {"success": False}
This is a real fix. stock > 0 moved into the WHERE clause, so the database evaluates the condition and applies the write under one row lock. rowcount tells you whether you won. No overselling.
But now there are two writes and no boundary around them. If the process is OOM-killed, the pod is rescheduled, the connection drops, or create_order raises between those lines — stock is decremented and no order exists. You didn't oversell. You undersold: a seat that nobody bought and nobody can now buy. It's a quieter bug, which makes it worse. Nobody files a support ticket for a seat that silently vanished. You find it during reconciliation, weeks later, and you can't explain the drift.
Also worth noting, because it survives every version below: f"... where id = {ticket_id}". If ticket_id ever arrives from a request body without validation, that's SQL injection. It's in every iteration of this function and no amount of concurrency reasoning removes it. Parameterize.
Fix: wrap both writes in one transaction.
Layer 2: transactional, and now a queue
async def buy_ticket(ticket_id, user_id):
async with db.transaction():
row_data = await db.execute(f"update tickets set stock = stock - 1 where id = {ticket_id} and stock > 0")
if row_data.rowcount > 0:
await db.create_order(ticket_id, user_id)
return {"success": True}
return {"success": False}
Atomic and durable. Either both writes land or neither does.
What's new is contention. That UPDATE takes an exclusive lock on the ticket row, and the lock is now held until the transaction commits — not until the statement finishes. Every other buyer for the same ticket blocks on that row. A popular event doesn't get a race condition anymore; it gets a serial queue with your commit latency as the service time.
That's arguably fine at this size, because the transaction is short. The lesson is the mechanism: transaction length is lock duration. Remember that for the next layer.
Two smaller things. First, return inside async with triggers __aexit__, so the commit still happens — but it's worth knowing that's why it works, rather than assuming it. Second, if this function ever touches a second row (a coupon, a related ticket) in an order that another code path touches in reverse, you have a deadlock, and no test you write will find it.
Layer 3: explicit lock, and now the queue is long
async def buy_ticket(ticket_id, user_id):
async with db.transaction():
row_data = await db.execute(f"select * from tickets where id = {ticket_id} and stock > 0 for update")
if row_data.rowcount > 0:
await db.apply_discount(ticket_id, 10)
await db.execute(f"update tickets set stock = stock - 1 where id = {ticket_id} and stock > 0")
await db.create_order(ticket_id, user_id)
return {"success": True}
return {"success": False}
This is where the reasoning goes subtly backwards.
SELECT ... FOR UPDATE looks like more safety than Layer 2 — an explicit lock, a deliberate read-modify-write. It is correct. It's also the version that falls over first.
The lock is now taken at the top and held across apply_discount, the update, and create_order. Everything between the FOR UPDATE and the commit is inside the critical section. Layer 2 held the row for the length of two statements; this holds it for the length of the whole business flow. Add one slow thing — a discount rule that hits another table, an audit log, a pricing call — and hold time goes from single-digit milliseconds to hundreds. Multiply by the queue from Layer 2. Now: lock wait timeouts, blocked connections piling up, connection pool exhaustion, and a 500 rate on endpoints that have nothing to do with ticketing, because they can't get a connection either.
The failure has migrated. Layers 0–2 were correctness bugs in this function. This one is a capacity bug in your whole service, caused by this function.
And note what happens if apply_discount calls an external payment or pricing API. Now you're holding a database row lock across a network call to a system you don't control, with its timeout, not yours. Never do this. External calls go outside the transaction; the transaction records intent, and a separate worker performs the side effect.
Two more issues here. rowcount on a SELECT is driver-dependent and often meaningless — check the returned rows. And re-checking stock > 0 in the UPDATE after locking is harmless but tells you the author wasn't sure which line was doing the protecting.
Layer 4: idempotent, in the shape of idempotency
async def buy_ticket(ticket_id, user_id, idempotency_key):
cache_exists = db.read_from_cache(idempotency_key)
if cache_exists:
return {"success": True}
async with db.transaction():
...
await db.write_to_cache(idempotency_key, {"success": True})
return {"success": True}
return {"success": False}
The intent is right. Retries are not optional: clients retry, load balancers retry, users double-tap. Without a key, a retry is a second purchase.
The implementation has four problems, in ascending order of how long they'd take you to find.
The missing await. db.read_from_cache(...) is an async call that isn't awaited. It returns a coroutine object, which is always truthy. So cache_exists is always true, and every request short-circuits to {"success": True} without selling anything. The endpoint returns 200 for every purchase and the tickets table never moves. This is a one-token bug that inverts the function's entire behavior, and no type checker in a dynamically-typed request handler catches it. write_to_cache isn't awaited either, so the write may never be scheduled.
The cache isn't in the transaction. The write happens inside async with db.transaction(), but Redis doesn't participate in a Postgres transaction. If the commit fails after the cache write lands, the key says "this purchase succeeded" and no order exists. The retry now gets a cheerful success for an order that isn't there.
The check-then-write is itself a race. This is Layer 0 again, one level up. Two retries arriving simultaneously both read a missing key, both proceed, both buy. The idempotency check has the exact TOCTOU gap the whole exercise started by fixing.
Failures aren't idempotent, and keys aren't scoped. Sold-out returns {"success": False} without recording anything, so a retry re-runs the entire flow — fine here, dangerous once there's a payment. And the key isn't scoped to user_id, so a client that generates keys carelessly can collide with, or replay, another user's purchase.
The pattern

Read the five versions as one thing. Every fix was a real fix. Every fix relocated the failure:
| fixed | introduced | |
|---|---|---|
| 0 → 1 | overselling | partial write |
| 1 → 2 | partial write | serialization on the hot row |
| 2 → 3 | (nothing; looked safer) | lock held across business logic |
| 3 → 4 | duplicate purchases | success without a purchase |
Nobody in this sequence was reasoning badly. Each step responded correctly to the problem in front of it, using the standard remedy, and the standard remedy moved the problem somewhere the current test couldn't see.
Why models land here specifically
Three reasons, and none of them is "the model doesn't know about transactions." Ask it directly and it will explain MVCC to you at length.
The invariants were never in the prompt. "Buy a ticket" states a happy path. It doesn't state: never sell the same seat twice; never decrement without an order; never hold a lock across a network call; a retry must be a no-op; two concurrent buyers must both get a definite answer. Those are the actual requirements, and they exist in your head as tacit knowledge from the last outage you sat through. The model writes to the spec it's given. So do junior engineers. The difference is that the model writes fluently enough that the gap doesn't look like a gap.
Most code that exists is happy-path code. The training distribution is tutorials, examples, and READMEs. SELECT then if then UPDATE is the most common shape of this function in the corpus by a wide margin, because it's the clearest way to explain buying a ticket. Clarity and correctness under concurrency point in opposite directions here, and the corpus optimizes for clarity.
The verification loop only exercises one request. You run it, it works, you move on. Concurrency bugs, crash-consistency bugs, and retry bugs are invisible to single-request verification by construction — they require two callers, or a process death at a specific line, or the same call twice. If the feedback signal can't see the bug, no amount of iteration converges on fixing it. This is the real reason it's always this layer: it's the first layer where "I ran it and it worked" stops being evidence.
What actually holds

Roughly this shape, with the parts that matter called out:
async def buy_ticket(ticket_id, user_id, idempotency_key):
# 1. Claim the key with a unique constraint, not a read-then-write.
# The database resolves the race; the application doesn't try to.
try:
async with db.transaction():
await db.execute(
"insert into idempotency_keys (key, user_id, state) values ($1, $2, 'in_progress')",
idempotency_key, user_id,
)
except UniqueViolation:
# Someone got here first: this retry, or a concurrent duplicate.
# Return the stored terminal result, or 409 if still in progress.
return await resolve_existing(idempotency_key, user_id)
# 2. Short transaction. One conditional update. No external calls inside.
async with db.transaction():
row = await db.execute(
"update tickets set stock = stock - 1 where id = $1 and stock > 0",
ticket_id,
)
if row.rowcount == 0:
result = {"success": False, "reason": "sold_out"}
else:
order_id = await db.create_order(ticket_id, user_id, idempotency_key)
result = {"success": True, "order_id": order_id}
# 3. Terminal result recorded in the same transaction as the effect.
await db.finalize_key(idempotency_key, result)
return result
The load-bearing decisions:
- Uniqueness constraints beat application-level checks.
insert ... on conflictor a caughtUniqueViolationis atomic;if not existsis not. Same principle as movingstock > 0into theWHEREclause — let the one component that has a serialization point do the serializing. - Idempotency state lives in the same database as the effect, so it commits or rolls back with it. Redis is a cache in front of that, never the record.
- Record terminal failures too. Sold-out is an answer; a retry should get the same answer, not a fresh attempt.
- Transactions contain writes, not workflows. Discounts, emails, payments, analytics go outside — or into an outbox row that a worker drains after commit.
- Parameterize every query. Five iterations of this function and the f-string never went away, because nothing about concurrency reasoning brings it to mind.
What to change in review
The prompt-level fix is to state the invariants up front — put "must be safe under concurrent callers, crash-consistent, and idempotent on retry" in the request, and the output changes materially. Worth doing, but it only helps for the risks you already thought of.
The durable fix is four questions, asked of any state-mutating code, whoever wrote it:
- Two at once. Run this twice concurrently, interleaved at the worst line. What's the resulting state?
- Crash here. Kill the process between each pair of adjacent lines. Which of those leaves the system inconsistent?
- Twice in a row. Call it again with identical inputs. Does anything happen a second time?
- Who's waiting. What lock does this hold, from which line to which line, and what else needs it?
Question 4 is the one nobody asks, and it's the one that took down Layer 3.
None of this is new. Every item is standard practice that predates AI-generated code by decades. What's changed is the failure mode of the process: code now arrives at review already fluent, already idiomatic, already passing the obvious test. The old signals for "this author hadn't thought it through" — awkward phrasing, missing error handling, unidiomatic structure — are gone. The gap moved from the code's surface to its concurrency semantics, and that's the one place you have to go looking on purpose.