Philipp Lehmann @PhilippTheServer

Preventing Overselling with a Database CHECK Constraint

A row lock and a CHECK constraint are enough to stop two buyers taking the last unit.

The problem

An online shop has one unit left of an item. Two customers click “buy” within the same second. Somewhere in the checkout code there is logic that looks roughly like this:

-- Application code, roughly, as three separate round trips:
-- 1. Read the current stock
SELECT stock FROM products WHERE id = 1;

-- 2. In the application: if stock >= quantity requested, proceed

-- 3. Write the new stock
UPDATE products SET stock = stock - 1 WHERE id = 1;

Each of those is a separate statement, and nothing stops another connection from running its own step 1 between this connection’s step 1 and step 3. Run it once, by hand, and it works: read 1, check 1 >= 1, write 0. Run it under real concurrency and it does not reliably work, because “read the stock” and “write the new stock” are not one operation — they are two, with an arbitrary amount of application logic in between: fraud checks, payment authorisation, building an order record. Any of that gives a second request time to run its own read against the same, still-unchanged, row.

The result is two confirmed orders against one unit of stock. Nothing in the code above raised an error, because nothing in it, or in the schema, said stock could not go negative. This is easy to miss in testing, because the failure needs two requests to land inside the same short window, which mostly does not happen with a single tester clicking a button by hand. It shows up in production, under real traffic, and by the time anyone notices, the shop owes an apology to whichever customer arrives second in the fulfilment queue.

The fix is not “add more validation in the application”. It is to stop treating the check and the decrement as two decisions, and make them one.

Working through it

Why the interleaving happens even inside a transaction

Wrapping the three steps in BEGIN / COMMIT looks like it should fix this — a transaction is supposed to be the unit of atomicity. It does not, for a specific reason: PostgreSQL’s default isolation level, READ COMMITTED, gives each statement inside a transaction its own snapshot of committed data, not the whole transaction. A SELECT early in the transaction and an UPDATE later in the same transaction can legitimately see two different states of the row, because a COMMIT from somewhere else can land in between.

Two sessions, interleaved:

Session A Session B
BEGIN;  
SELECT stock ... returns 1  
  BEGIN;
  SELECT stock ... returns 1
checks 1 >= 1, decides to sell checks 1 >= 1, decides to sell
UPDATE ... SET stock = stock - 1;  
COMMIT; — stock is now 0  
  UPDATE ... SET stock = stock - 1;
  COMMIT; — stock is now -1

Both sessions read the same value because neither had committed yet when the other read. Both decided, independently and correctly given what they saw, that the sale was fine. The transaction boundary did nothing here, because the decision was made on data the transaction never protected from being read, and possibly acted on, by someone else.

Isolation levels change where it breaks, not whether it breaks

Raising the isolation level to REPEATABLE READ or SERIALIZABLE does not sidestep this so much as move the failure to a place where the database can tell you about it. Under either of those, the second session’s UPDATE would fail at commit time with a serialisation error, because PostgreSQL detects that the transaction acted on data another transaction has since changed. That is a real improvement — nothing is silently oversold — but it comes with a cost the application has to carry: every write path that touches contended rows now needs a retry loop for serialisation failures, and those failures become more frequent as concurrency on the same row increases, which is exactly when you can least afford wasted work.

SELECT ... FOR UPDATE is the other classic answer: take a row lock on read, so a second SELECT ... FOR UPDATE against the same row blocks until the first transaction commits or rolls back, then reads the value the first transaction left behind. This does prevent the race, correctly used. The problem is “correctly used” has to hold on every code path that touches the row, forever — an admin script, a bulk import job, or a later refactor that adds a second way to adjust stock only has to skip the FOR UPDATE once to reopen the hole, and nothing in the schema will tell you it happened.

Making the decision and the write the same operation

The more direct fix is to stop asking “is there enough stock” and then separately telling the database to decrement it. Ask the database to decrement it only if there is enough stock, as a single statement:

UPDATE products
SET stock = stock - :qty
WHERE id = :id AND stock >= :qty;

This is atomic for a concrete, mechanical reason: PostgreSQL takes a row-level lock for the duration of an UPDATE. A second, concurrent UPDATE against the same row does not race the first one — it waits for the first to finish, and then evaluates its own WHERE clause against whatever the first transaction left behind. Under plain READ COMMITTED, with no elevated isolation level and no explicit locking clause, the two updates are serialised by the row lock alone. Whichever one commits first sees the original stock; whichever one runs second sees the already-decremented value, and its WHERE clause either still matches or it does not.

The row count the UPDATE reports is now the answer to “did the sale succeed”: one row changed means yes, zero rows changed means no. That is a plain number the application checks, not an exception it has to catch.

A CHECK constraint as the backstop, not the mechanism

The atomic UPDATE is the mechanism that makes the correct code path safe. It does nothing for a careless one. A manual fix run by hand, a bulk import, or an admin panel that updates stock without the WHERE stock >= qty clause can still push the value negative, and nothing about the atomic UPDATE pattern stops a different piece of code from ignoring it.

A CHECK constraint closes that gap at the level where it cannot be skipped:

CONSTRAINT stock_not_negative CHECK (stock >= 0)

This is enforced by PostgreSQL itself, on every statement that touches the row, regardless of which code path wrote it. The atomic UPDATE and the constraint are doing different jobs. The UPDATE ... WHERE picks the correct outcome when two legitimate requests compete for the same stock. The CHECK constraint guarantees the invariant — stock never goes negative — even when something did not use the safe pattern at all. Neither one replaces the other.

The solution

Schema

-- schema.sql
CREATE TABLE products (
    id    bigint PRIMARY KEY,
    name  text NOT NULL,
    stock integer NOT NULL,
    CONSTRAINT stock_not_negative CHECK (stock >= 0)
);

INSERT INTO products (id, name, stock) VALUES (1, 'Last unit widget', 1);

-- The naive pattern from "The problem", kept here to demonstrate why it is
-- unsafe even with the constraint in place. Do not copy this into an
-- application.
CREATE OR REPLACE FUNCTION buy_naive(p_id bigint, p_qty integer)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
    v_stock integer;
BEGIN
    SELECT stock INTO v_stock FROM products WHERE id = p_id;

    -- Stands in for the application work that happens between reading the
    -- stock and writing it back: fraud checks, payment capture, building
    -- the order record. Widens the race window so it reproduces reliably
    -- on a single laptop instead of depending on timing luck.
    PERFORM pg_sleep(1);

    IF v_stock < p_qty THEN
        RAISE EXCEPTION 'insufficient stock for product %', p_id;
    END IF;

    UPDATE products SET stock = stock - p_qty WHERE id = p_id;
END;
$$;

-- The safe pattern: the check and the write are one statement.
CREATE OR REPLACE FUNCTION buy_atomic(p_id bigint, p_qty integer)
RETURNS boolean
LANGUAGE plpgsql
AS $$
DECLARE
    v_rows integer;
BEGIN
    UPDATE products
    SET stock = stock - p_qty
    WHERE id = p_id AND stock >= p_qty;

    GET DIAGNOSTICS v_rows = ROW_COUNT;
    RETURN v_rows = 1;
END;
$$;

Start a throwaway instance and load it:

docker run -d --name stock-demo -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16
sleep 3
DB="postgresql://postgres:postgres@localhost:5432/postgres"
psql "$DB" -f schema.sql

Proving the naive version fails badly, not safely

Reset stock to one unit, then fire two concurrent calls to buy_naive:

psql "$DB" -c "UPDATE products SET stock = 1 WHERE id = 1;"

psql "$DB" -c "SELECT buy_naive(1, 1);" &
psql "$DB" -c "SELECT buy_naive(1, 1);" &
wait

Both calls read stock = 1 before either commits, because neither has written yet — the pg_sleep(1) guarantees the overlap. Both pass their IF v_stock < p_qty check. The first UPDATE then commits, taking stock from 1 to 0. The second UPDATE was blocked on the row lock; once it runs, it computes 0 - 1 and the CHECK constraint rejects it:

ERROR:  new row for relation "products" violates check constraint "stock_not_negative"
DETAIL:  Failing row contains (1, Last unit widget, -1).

The constraint did its job — stock never actually reached -1 in the table. But the pattern is still the wrong one. The second customer’s request ran the full one-second simulated checkout — the fraud check, the payment capture, the order-record work the pg_sleep stands in for — and only failed at the very last statement, as a database exception the application now has to catch and unwind, including any external side effects it already started. The constraint prevented data corruption; it did not prevent the underlying design mistake of deciding “yes” before checking whether the write would actually succeed.

Proving the atomic version holds under concurrency

Reset stock again, then fire two concurrent calls to buy_atomic:

psql "$DB" -c "UPDATE products SET stock = 1 WHERE id = 1;"

psql "$DB" -tAc "SELECT buy_atomic(1, 1);" &
psql "$DB" -tAc "SELECT buy_atomic(1, 1);" &
wait

psql "$DB" -c "SELECT stock FROM products WHERE id = 1;"

One call prints t, the other prints f, and the final query shows stock = 0. No exception, no rollback, no pg_sleep needed to demonstrate it — the decision and the write happen as one statement, so there is no window in which both callers can believe they succeeded.

A wider stress test makes the same point at higher concurrency, which is the case that matters:

psql "$DB" -c "UPDATE products SET stock = 1 WHERE id = 1;"

for i in $(seq 1 20); do
  psql "$DB" -tAc "SELECT buy_atomic(1, 1);" &
done
wait

echo "Successful buys:"
psql "$DB" -tAc "SELECT stock FROM products WHERE id = 1;"

Twenty concurrent callers compete for one unit of stock. Exactly one of them can ever receive t, because each UPDATE acquires the row lock in turn and re-evaluates stock >= 1 against whatever the previous holder left behind. The final stock value is always 0, never negative, regardless of how many callers you add — the number of concurrent callers changes how much they queue behind the row lock, not whether the outcome is correct.

Conclusion

The mistake in the original code was never the arithmetic. stock - 1 is not wrong. The mistake was splitting “decide whether to sell” and “record the sale” into two statements and trusting that nothing would happen in between.

A few points generalise beyond stock counters:

An invariant that must always hold belongs in the schema, not only in application code. CHECK (stock >= 0) costs one line and turns “a code path forgot to validate this” from a silent data-integrity bug into an error PostgreSQL raises on your behalf, from every code path, including the ones you have not written yet.

When a decision and a write must be consistent with each other, make them one statement. UPDATE ... WHERE stock >= qty is not a style preference over SELECT then UPDATE — it changes what the database can guarantee, using nothing more than the row lock every UPDATE already takes, at the default isolation level, with no retry loop required.

A failed row count is not an exceptional case; it is the normal shape of “no”. The atomic pattern makes rejection a value the caller checks, rather than an exception raised after work has already been done that now has to be undone.

This solves correctness, not availability. A CHECK constraint and an atomic UPDATE do not implement a reservation queue — there is no notion of “held for the next five minutes while payment completes”, and no partial-fulfilment or backorder logic; a request for more than is available simply fails outright. Under very high contention for a single row, every writer still queues behind the same row lock, so this protects correctness under load without improving how much load one row can absorb — if that becomes the bottleneck, it is a capacity problem to solve separately, for example by batching demand or partitioning stock across rows, not a reason to go back to check-then-decrement.