Handling IntegrityError on concurrent inserts

An IntegrityError poisons the transaction: every subsequent statement raises PendingRollbackError until you roll back, so the handler must roll back first and only then decide what to do — and in most cases the right answer is to remove the race with an upsert rather than to retry. This is the failure mode behind upserts and conflict resolution strategies.

Quick Answer

Why the next statement fails too Five stages. A concurrent insert wins the race and commits first. Our insert violates the unique constraint, and the database aborts the whole transaction — not just the statement. SQLAlchemy marks the session as requiring a rollback, so any further statement raises PendingRollbackError with a message pointing back at the original error. Only an explicit rollback clears that state. The mistake this causes is subtle: a handler that catches IntegrityError and immediately re-queries to fetch the existing row gets PendingRollbackError instead, and the traceback names the wrong problem. a concurrent insert commits first the race is lost, not detected ordinary under load our INSERT violates the constraint the DATABASE aborts the transaction not just the statement the session is marked for rollback every further statement raises PendingRollbackError roll back explicitly or the savepoint, if nested nothing else clears it now the session is usable again re-query, or return the conflict A handler that catches IntegrityError and immediately re-queries gets PendingRollbackError instead — and the traceback then names the wrong problem.

Before — a handler that raises a different error than it catches:

try:
    session.add(User(email=email))
    await session.commit()
except IntegrityError:
    # PendingRollbackError: the session is unusable until it is rolled back
    existing = await session.scalar(select(User).where(User.email == email))
    return existing

After — roll back first, then decide:

from sqlalchemy.exc import IntegrityError


async def get_or_create_user(session: AsyncSession, email: str) -> User:
    try:
        user = User(email=email)
        session.add(user)
        await session.flush()          # flush, not commit: the caller owns the boundary
        return user
    except IntegrityError as exc:
        await session.rollback()       # FIRST — nothing else works until this runs
        if constraint_of(exc) != "uq_users_email":
            raise                      # a different constraint is a different bug
        return await session.scalar(select(User).where(User.email == email))

Better — no error path at all:

stmt = insert(User).values(email=email)
stmt = stmt.on_conflict_do_update(
    index_elements=[User.email],
    set_={"email": stmt.excluded.email},    # a no-op update, so RETURNING yields the row
).returning(User)
user = (await session.execute(stmt)).scalar_one()

The third version has no race, no rollback, no re-query and no constraint-name check. When the answer to "what should happen on conflict" is "give me the row that exists", the exception path was never needed.

Execution Context & Async Workflow Integration

The rollback requirement is a property of the database, not of SQLAlchemy: PostgreSQL aborts the entire transaction on a constraint violation, and no further statement is accepted on that connection until the transaction ends. SQLAlchemy surfaces this as PendingRollbackError, which is a clearer failure than the driver's own, but the constraint is the database's.

That has a consequence for anything doing several inserts in one transaction. A single failure discards all of them, which is right when they are one unit of work and wrong when they are independent. For the independent case, a savepoint per item scopes the failure:

# Async — one item's conflict must not discard the whole batch
async def import_users(session: AsyncSession, emails: list[str]) -> tuple[int, int]:
    created = skipped = 0
    for email in emails:
        try:
            async with session.begin_nested():        # SAVEPOINT per item
                session.add(User(email=email))
        except IntegrityError:
            skipped += 1                              # only this savepoint rolled back
        else:
            created += 1
    await session.commit()
    return created, skipped

Exiting the begin_nested() block on an exception rolls back to the savepoint rather than to the start of the transaction, so the successfully created users survive. That is the correct shape for an import, and it is worth noting the cost: a savepoint per row is a round trip per row, so for a large import the upsert form with DO NOTHING is both simpler and dramatically faster.

Under async there is one more trap. session.rollback() is a coroutine, so a handler that forgets to await it appears to work — the coroutine is created and discarded, the session stays poisoned, and the next statement raises PendingRollbackError from a line that looks unrelated. A RuntimeWarning: coroutine ... was never awaited is the clue, and it is easy to miss in a busy log.

Three responses, and one that is not Three legitimate responses. Converting the error into a domain outcome is right when losing the race is a real business answer — the slot is taken, the username exists. Re-reading the winning row is right when either row would have been acceptable and the caller simply needs one, which is the get-or-create shape. Retrying the whole unit of work is right when the conflict was incidental to a longer operation, and it must re-read everything rather than replay the failed statement. Swallowing the error and continuing is almost always wrong: the transaction is already aborted, so nothing after it will work anyway. convert to a domain outcome the slot is taken; the name exists a real answer, not an error re-read the winning row get-or-create either row was acceptable retry the unit of work roll back, re-read, redo bounded attempts only swallow and continue the transaction is already aborted nothing after it will work If the answer is the second column, an upsert removes the round trip and the error path entirely — which is why get-or-create is the shape most worth replacing.

Resolving Warnings, Errors & Common Mistakes

Error / WarningRoot CauseProduction Fix
PendingRollbackError: This Session's transaction has been rolled back due to a previous exception during flushA statement was issued after an IntegrityError without rolling back.await session.rollback() as the first line of the handler.
RuntimeWarning: coroutine 'AsyncSession.rollback' was never awaitedThe rollback was called but not awaited.Await it. The session stays poisoned otherwise.
The retry loop never terminatesThe conflicting row is permanent, so every attempt fails identically.Bound the attempts, and re-read state between them — an unbounded retry of a deterministic failure is an outage.
A different constraint is swallowedThe handler catches IntegrityError broadly and assumes it was the expected one.Check the constraint name and re-raise anything else.
A batch import loses everything on one bad rowAll inserts share one transaction.A savepoint per item, or an upsert with DO NOTHING.
IntegrityError on a foreign key, not a unique constraintA referenced row was deleted concurrently.Different problem entirely: the fix is a FOR UPDATE lock or a deferred constraint, not a retry.

Advanced: Extracting the Constraint Name Portably

Deciding what to do about a violation requires knowing which rule was broken, and the string representation of the exception is the wrong place to find it — it varies by driver, by server version and by locale.

from __future__ import annotations

from sqlalchemy.exc import IntegrityError


def constraint_of(exc: IntegrityError) -> str | None:
    """The violated constraint's name, from the driver exception rather than its text."""
    orig = getattr(exc, "orig", None)
    if orig is None:
        return None
    # asyncpg exposes it directly on the exception object.
    name = getattr(orig, "constraint_name", None)
    if name:
        return name
    # psycopg exposes it through the diagnostics object.
    diag = getattr(orig, "diag", None)
    return getattr(diag, "constraint_name", None) if diag is not None else None


class SlotTaken(Exception):
    pass


class EmailAlreadyRegistered(Exception):
    pass


# One place where database rules become domain outcomes.
CONSTRAINT_ERRORS: dict[str, type[Exception]] = {
    "uq_users_email": EmailAlreadyRegistered,
    "uq_reservations_slot_active": SlotTaken,
}


async def translate_integrity_error(session: AsyncSession, exc: IntegrityError) -> None:
    """Roll back, then raise the domain error — or re-raise if we do not know this rule."""
    await session.rollback()
    domain = CONSTRAINT_ERRORS.get(constraint_of(exc) or "")
    if domain is None:
        raise exc
    raise domain() from exc

Two things make this hold up. The rollback happens before any decision, so the session is usable no matter which branch is taken. And an unmapped constraint re-raises the original error rather than being converted into something generic, so a new constraint added by a migration produces a clear failure rather than a misleading domain exception.

Naming constraints explicitly in migrations is what makes the mapping readable:

# In the migration, rather than accepting an auto-generated name
op.create_unique_constraint("uq_users_email", "users", ["email"])

An auto-generated name is stable enough to map, but a chosen one documents at the mapping site what rule it represents — and it survives a table rename, which auto-generated names often do not.

Identifying the constraint without parsing text Three steps. The SQLAlchemy IntegrityError wraps the driver exception, reachable through the orig attribute; parsing the string representation instead breaks on the first driver upgrade or locale change. asyncpg exposes the constraint name directly on that exception, and psycopg exposes it through its diagnostics object — both are stable identifiers rather than prose. Mapping constraint names to domain errors in one dictionary keeps the translation in a single reviewable place, and makes an unmapped constraint an obvious gap rather than a silent re-raise of a database exception into a handler. read the driver exception, not the message exc.orig is the asyncpg or psycopg error string parsing breaks on the next driver upgrade take the constraint name from it asyncpg: constraint_name · psycopg: diag.constraint_name a stable identifier, not prose map names to domain errors in one place a single dict, reviewed as a unit an unmapped constraint is then an obvious gap Name your constraints deliberately in migrations. An auto-generated name is stable enough to map, but a chosen one documents what the rule means at the mapping site.

When a Retry Is Correct, and When It Is Not

Retrying is the reflex response to a concurrency error, and for IntegrityError it is usually the wrong one. The distinction is whether a subsequent attempt could plausibly succeed.

A unique-constraint violation is generally not retryable. The conflicting row now exists and will keep existing, so attempt two fails identically to attempt one. Retrying converts a fast, clear failure into a slow, identical one. The exception is a violation caused by a row that is itself transient — a lock or claim record another worker will delete — where a bounded retry with backoff is reasonable.

A serialisation failure is retryable, and is a different exception. Under SERIALIZABLE or REPEATABLE READ, PostgreSQL raises SQLSTATE 40001, which surfaces as OperationalError rather than IntegrityError, and the whole point of that error is that a retry may succeed. That retry must re-read everything, as described in setting transaction isolation level per session.

A deadlock is retryable. SQLSTATE 40P01 means the database chose your transaction as the victim, and the same work attempted again will usually succeed once the other transaction has finished.

# Retry only what is genuinely retryable, and always with a bound
RETRYABLE = {"40001", "40P01"}


def is_retryable(exc: Exception) -> bool:
    sqlstate = getattr(getattr(exc, "orig", None), "sqlstate", None)
    return sqlstate in RETRYABLE

Reading the sqlstate rather than the exception class is what keeps this honest: OperationalError covers connection failures too, and retrying a unit of work because the connection dropped is a different decision from retrying because the database asked you to.

The broader point is that an IntegrityError under concurrency is usually a signal that the write should have been expressed differently. If losing the race is a business outcome, name it and return it. If either row would do, upsert. A retry loop around a unique violation is the shape that has neither decided which of those it is nor removed the race.

Frequently Asked Questions

Why does the next query fail after I catch IntegrityError?

Because PostgreSQL aborted the whole transaction, not just the failing statement. SQLAlchemy reports the attempt to continue as PendingRollbackError. Roll back as the first line of the handler and the session becomes usable again.

Can I avoid the rollback with a savepoint?

Yes, and for a per-item import that is the right shape: async with session.begin_nested(): around each item means a violation rolls back to the savepoint and leaves earlier work intact. The cost is a round trip per item, so for large volumes an upsert with DO NOTHING is both simpler and much faster.

Should I parse the error message to find the constraint?

No. Read exc.orig and take constraint_name from it — asyncpg exposes it directly, psycopg through diag. Message text varies with driver, server version and locale, so parsing it breaks on upgrades in a way that is hard to test for.

Is catching IntegrityError around a whole request acceptable?

As a last-resort safety net that rolls back and returns a 409, yes. As the primary mechanism, no: it cannot tell which constraint fired, it discards the whole transaction, and it turns a distinguishable set of outcomes into one generic failure.