The Select-Then-Insert Race Condition in Version Counters
A `SELECT MAX()+1` then `INSERT` pattern looks correct in development and fails silently under any real concurrency. The fix lives in the database, not the application.

The problem
You need a monotonically increasing version counter on a table — an event offset, a row version for incremental sync, an instruction cursor. The naive implementation writes itself quickly:
# Get the current max, add 1, use that as the next version
result = db.execute(
"SELECT COALESCE(MAX(version), 0) + 1 FROM events WHERE tenant = :t",
{"t": tenant}
).scalar()
new_event = Event(tenant=tenant, version=result, ...)
db.add(new_event)
db.commit()Two reads, one write. Looks fine in development. Fails in production under any concurrency.
Why the naive approach fails
The SELECT and the INSERT are two separate statements with no locking between them. Under concurrent load:
- Request A reads
MAX(version) = 5, computes6 - Request B reads
MAX(version) = 5before A commits, also computes6 - Both insert with
version = 6
You now have a duplicate version. Any consumer polling WHERE version > :cursor will see one of the two rows, silently miss the other, and advance its cursor past both. Rows disappear from the feed with no error.
The window is small in local testing and grows under any real concurrency. A gunicorn worker with 4 processes hits this on the second concurrent request.
Making this worse: adding a unique constraint on version turns the silent data loss into an intermittent 500 — harder to debug, same root cause.
The fix
Move version assignment entirely to the database. Postgres sequences are designed for exactly this:
-- Run once at startup (idempotent)
CREATE SEQUENCE IF NOT EXISTS event_version_seq;
ALTER TABLE events
ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT nextval('event_version_seq');With DEFAULT nextval(...), every INSERT statement that omits the version column gets a unique, monotonically increasing value assigned atomically by Postgres. The Python application layer never reads the current max and never races.
The SQLAlchemy model needs no changes beyond declaring the column as nullable:
version = Column(BigInteger, nullable=True)Existing rows keep NULL (the sequence was not backfilled, which is correct — there is no meaningful order for pre-migration rows). Incremental poll consumers exclude NULL-versioned rows explicitly:
if since_version is not None:
query = query.filter(
Event.version != None,
Event.version > since_version,
)The version != None condition is not just defensive — it is load-bearing. Without it, every poll with since_version=0 re-delivers every pre-migration row. The filter is AND NOT NULL, not OR NULL.
What I learned
The root cause of most version-counter bugs is the assumption that the application layer controls the insertion order. It doesn't — the OS scheduler does. Any two-statement pattern (read-then-write, select-then-insert, check-then-act) can be interleaved by a concurrent request between the two statements.
The correct mental model: if a value must be unique and increasing across concurrent inserters, let the database assign it. Sequences, identity columns, and ON CONFLICT DO UPDATE RETURNING are the right tools. Python-side MAX()+1 is not.
The secondary lesson: a NULL guard on the filtered side of an incremental sync is not optional. Pre-migration rows are a permanent fixture of any ADD COLUMN IF NOT EXISTS migration. The polling consumer must know which rows to skip, and the filter must express that explicitly.
