How Two Code Paths Locking the Same Two Tables in Opposite Order Took Down Checkout
← Back
August 22, 2026Database8 min read

How Two Code Paths Locking the Same Two Tables in Opposite Order Took Down Checkout

Published August 22, 20268 min read

10:47 UTC. The checkout error rate alert fires, then fires again ninety seconds later at a higher threshold. Support's Slack channel starts filling with "payment went through, order says failed" messages faster than anyone can triage them. Nothing shipped in the last hour. The last deploy was a marketing banner change, eighteen hours earlier.


the setup

Checkout on our platform runs two updates inside one transaction: decrement inventory.quantity for the purchased SKU, then write the new orders row with the loyalty points earned on that purchase. Both statements sit inside a single BEGIN/COMMIT block so a partial failure can't leave stock decremented with no order to show for it.

A separate nightly reconciliation job, added eight months earlier by a different engineer to patch loyalty point rounding errors, does the same two updates for a batch of orders: it walks a list of recently corrected orders, updates each orders row's point total, then re-derives and writes the matching inventory adjustment for any SKU where a refund had changed stock. Same two tables. Same transaction. Reverse order.

That morning, a promo email went out three hours ahead of a flash sale still queued for the afternoon. Some subset of recipients checked out immediately instead of waiting, which pushed checkout concurrency to roughly four times its normal weekday floor, right as the reconciliation job's extended morning run (it had a backlog from a holiday) was still working through orders from the day before.


the scramble

First theory in the incident channel: the connection pool. Checkout traffic was up sharply, so the obvious guess was PgBouncer running out of connections and checkout requests queueing behind each other. SHOW POOLS showed headroom, nowhere near the configured max, and the errors weren't connection timeouts. They were coming back from the application's own commit call, with a Postgres error attached.

Second theory: the new loyalty-points index added two weeks earlier was slow under this kind of concurrent write load and checkout requests were timing out waiting on a lock from a long-running query. pg_stat_activity didn't back that up either, nothing had been running long enough to explain requests failing within milliseconds of being submitted.

Someone finally pulled the actual error text out of the application logs instead of guessing from the symptom.

application error log, checkout service
psycopg2.errors.DeadlockDetected: deadlock detected
DETAIL:  Process 41822 waits for ShareLock on transaction 88213411; blocked by process 41905.
Process 41905 waits for ShareLock on transaction 88213398; blocked by process 41822.
HINT:  See server log for query details.

Not a slow query. Not a pool problem. Postgres itself had detected a deadlock between two transactions and killed one of them to break the cycle, which is exactly what it's supposed to do, at a rate high enough that checkout's error rate was now visible from the outside.


the hunt

Postgres logs the two competing statements whenever it kills a transaction to resolve a deadlock, provided log_lock_waits is on, which it was. Pulling the matching server-log entries for the two process IDs in the error above gave the actual query pair.

postgresql.log, deadlock detail
Process 41822: UPDATE inventory SET quantity = quantity - 1 WHERE sku_id = 'SKU-40218'
Process 41822: UPDATE orders SET loyalty_points = 340 WHERE id = 902117
Process 41905: UPDATE orders SET loyalty_points = 155 WHERE id = 901884
Process 41905: UPDATE inventory SET quantity = quantity + 1 WHERE sku_id = 'SKU-40218'

Process 41822 was checkout: lock inventory row first, then orders row. Process 41905 was the reconciliation job, applying a refund adjustment for the same SKU: lock orders first, then inventory. Two transactions, same two rows, opposite acquisition order. When their timing overlapped closely enough, each held the lock the other one needed next, and Postgres had no option but to abort one of them.

Under normal traffic this pattern existed but almost never triggered, the reconciliation job ran a few hundred updates a night against a mostly idle checkout path. The promo-driven concurrency spike, combined with the reconciliation job still catching up on a backlog well past its usual overnight window, put enough concurrent transactions through both code paths at once that collisions on hot SKUs became routine instead of rare.

Querying pg_stat_database confirmed the shape of it once we knew what to look for.

psql on the primary, mid-incident
SELECT deadlocks FROM pg_stat_database WHERE datname = 'prod';
-- deadlocks: 214 (baseline for a normal day: under 5)

the find

Root cause: checkout and the reconciliation job updated inventory and orders in opposite order inside their own transactions. Neither path was wrong on its own, both did necessary work. The bug was that nothing enforced a consistent lock order between the two code paths, so given enough concurrent overlap, Postgres's deadlock detector was going to fire eventually. A traffic spike plus a delayed batch job supplied that overlap.

Every deadlock Postgres kills becomes a failed transaction at the application layer. Checkout had no retry logic for a serialization failure, it treated any database exception as a hard checkout failure and returned an error to the customer, even though the charge had frequently already gone through via the separate payment provider call earlier in the request. That's what produced the "charged but order failed" reports flooding support.


the fix

The immediate fix was pausing the reconciliation job's run and letting the backlog clear at a slower, throttled rate outside checkout's peak window, which stopped new deadlocks within a few minutes while we worked the real fix.

The real fix was forcing both code paths to acquire locks on inventory and orders in the same order, always inventory first. That alone removes the deadlock condition, two transactions can still block each other briefly, but they can no longer form a cycle.

reconciliation/apply_adjustment.py (before)
def apply_adjustment(conn, order_id, sku_id, point_delta, qty_delta):
    with conn, conn.cursor() as cur:
        cur.execute(
            "UPDATE orders SET loyalty_points = loyalty_points + %s WHERE id = %s",
            (point_delta, order_id),
        )
        cur.execute(
            "UPDATE inventory SET quantity = quantity + %s WHERE sku_id = %s",
            (qty_delta, sku_id),
        )
reconciliation/apply_adjustment.py (after)
def apply_adjustment(conn, order_id, sku_id, point_delta, qty_delta):
    # locking order matches checkout: inventory before orders, always
    with conn, conn.cursor() as cur:
        cur.execute(
            "UPDATE inventory SET quantity = quantity + %s WHERE sku_id = %s",
            (qty_delta, sku_id),
        )
        cur.execute(
            "UPDATE orders SET loyalty_points = loyalty_points + %s WHERE id = %s",
            (point_delta, order_id),
        )

As a backstop for the deadlock that's still theoretically possible under a different code path we haven't audited yet, checkout got retry logic specifically for serialization failures, since a deadlock loser in Postgres is always safe to retry from scratch.

checkout/db.py (after)
from psycopg2.errors import DeadlockDetected

def run_checkout_transaction(conn, fn, *args, max_attempts=3):
    for attempt in range(1, max_attempts + 1):
        try:
            with conn:
                return fn(conn, *args)
        except DeadlockDetected:
            if attempt == max_attempts:
                raise
            logger.warning(f"checkout deadlock, retrying (attempt {attempt})")
    

We also added an alert on pg_stat_database.deadlocks crossing a rate of 10 per minute, sustained for two minutes. It didn't exist before because deadlocks had never been frequent enough to be worth a dedicated alert, they were something you noticed in a monthly metrics review, not something that took down checkout.


the aftermath

11 min Elevated checkout error rate
214 Deadlocks logged during the incident
312 Checkout attempts that failed after a successful charge
3 Max retry attempts added to checkout's transaction wrapper

312 customers were charged for an order that then reported as failed, which meant 312 manual refund-or-fulfil decisions for support to work through over the following two days, a slower and more expensive cleanup than the eleven-minute outage itself.

  • A deadlock isn't a sign either query is wrong. Both updates here were correct in isolation. The bug lives entirely in the relationship between two code paths that neither engineer who wrote them had reason to look at side by side.
  • Lock ordering has to be a project-wide convention, not something enforced query by query. A code review on the reconciliation job alone would never have caught this, the reviewer would have needed checkout's transaction in front of them at the same time.
  • Deadlock detection working correctly still produces an outage if the application layer treats the aborted transaction as fatal instead of retryable. Postgres did its job. The gap was entirely in what checkout did after.
  • A background job with a backlog is a load multiplier on whatever it touches, not just a background job. The reconciliation job running late wasn't itself a problem until it overlapped a traffic pattern nobody had modeled it against.

Both code paths now go through a shared lock_order helper, and a lint rule flags any transaction that touches inventory and orders without acquiring them in the documented order. Convention alone wasn't enough to catch this the first time. The deadlock alert hasn't fired since the fix shipped.

Share this
← All Posts8 min read