Handling Deadlocks in Concurrent Spatial Updates

Why concurrent updates to overlapping geometry rows deadlock, and how to fix it: consistent lock ordering with SELECT ... FOR UPDATE ORDER BY id, retrying on PostgreSQL 40P01, and pg_advisory_xact_lock for hotspot geometries.

← Back to Async PostGIS Transaction Patterns

Stop concurrent updates to overlapping geometry rows from deadlocking by imposing a consistent lock order, retrying on PostgreSQL’s 40P01, and serialising hotspot writes with advisory locks.

Context & when to use

A deadlock happens when two transactions each hold a lock the other needs. In spatial workloads this is common because a single “update” often touches several rows — a parcel edit that also updates its neighbours’ shared boundaries, a merge that rewrites two overlapping polygons, or a recomputation that locks every feature inside a bounding box. If transaction A locks rows in the order (17, 42) and transaction B locks them as (42, 17), they can each grab one and wait forever for the other. PostgreSQL breaks the cycle by killing one transaction with ERROR: deadlock detected (SQLSTATE 40P01).

This is the concurrency counterpart to the width-and-boundary rules in the parent async PostGIS transaction patterns guide. Reach for these techniques when multiple writers update the same feature set concurrently — collaborative editing, ingestion that overlaps live edits (including the chunked loads in bulk geometry writes), or any endpoint where two requests can legitimately target overlapping geometries.

There are three complementary tools. Consistent lock ordering prevents most deadlocks outright. Retry on 40P01 handles the ones you cannot design away, because deadlocks are always possible in principle. Advisory locks serialise writers on a known hotspot so they queue politely instead of colliding.

How two spatial transactions deadlock

Deadlock from inconsistent lock ordering, and the ordered-lock fixTransaction A locks row 17 then waits for row 42. Transaction B locks row 42 then waits for row 17. Each holds what the other needs, forming a cycle that PostgreSQL aborts with error 40P01. The fix is for both transactions to lock rows in ascending id order so one waits cleanly for the other.Deadlock: opposite lock orderFix: same order (ORDER BY id)Tx Alocks row 17Tx Blocks row 42A waits for 42held by BB waits for 17held by AERROR: deadlock detectedSQLSTATE 40P01 — one tx abortedTx Alock 17 → 42Tx Block 17 → 42B waits at row 17, then proceedsno cycle — no deadlock

Runnable implementation

Two parts: a decorator that retries a transaction on 40P01/40001, and the SQL that locks rows in a deterministic order so the retries are rarely needed.

# app/concurrency/retry.py
import asyncio
import random
import functools
from asyncpg.exceptions import DeadlockDetectedError, SerializationError
from sqlalchemy.exc import DBAPIError

# PostgreSQL SQLSTATEs worth retrying:
#   40P01 = deadlock_detected, 40001 = serialization_failure
RETRYABLE_SQLSTATES = {"40P01", "40001"}


def _is_retryable(exc: Exception) -> bool:
    orig = getattr(exc, "orig", exc)
    if isinstance(orig, (DeadlockDetectedError, SerializationError)):
        return True
    return getattr(orig, "sqlstate", None) in RETRYABLE_SQLSTATES


def retry_on_deadlock(max_attempts: int = 5, base_delay: float = 0.05, cap: float = 1.0):
    """Retry an async transaction fn on deadlock/serialization failure.
    The wrapped fn MUST be idempotent: it may run several times."""
    def decorator(fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            attempt = 0
            while True:
                try:
                    return await fn(*args, **kwargs)
                except DBAPIError as exc:
                    attempt += 1
                    if attempt >= max_attempts or not _is_retryable(exc):
                        raise
                    # Exponential backoff with full jitter to de-synchronise contenders.
                    delay = min(cap, base_delay * (2 ** (attempt - 1)))
                    await asyncio.sleep(random.uniform(0, delay))
        return wrapper
    return decorator
# app/concurrency/spatial_writes.py
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from .retry import retry_on_deadlock


@retry_on_deadlock(max_attempts=5)
async def merge_overlapping_parcels(session: AsyncSession, ids: list[int]) -> None:
    """Rewrite several overlapping parcels atomically.
    Deadlock-safe because every writer locks rows in ascending id order."""
    async with session.begin():                      # one short, atomic transaction
        # 1. Deterministic lock order: ALWAYS ascending id. This is the whole trick.
        locked = await session.execute(
            text("""
                SELECT id, geom
                FROM parcels
                WHERE id = ANY(:ids)
                ORDER BY id            -- consistent order across all writers
                FOR UPDATE
            """),
            {"ids": sorted(ids)},
        )
        rows = locked.mappings().all()
        if len(rows) != len(ids):
            raise ValueError("Some parcels no longer exist")

        # 2. Now that all target rows are locked in order, do the spatial work.
        await session.execute(
            text("""
                UPDATE parcels
                SET geom = ST_MakeValid(ST_Union(geom) OVER ())
                WHERE id = ANY(:ids)
            """),
            {"ids": ids},
        )


@retry_on_deadlock(max_attempts=8)
async def update_hotspot_cell(session: AsyncSession, cell_key: int, delta_geom_wkb: bytes) -> None:
    """A single grid cell that many writers hit at once. Serialise them with an
    advisory lock instead of letting them fight over row locks."""
    async with session.begin():
        # Transaction-scoped advisory lock: auto-released at COMMIT/ROLLBACK.
        # All writers targeting the same cell_key queue on this one lock.
        await session.execute(
            text("SELECT pg_advisory_xact_lock(:k)"),
            {"k": cell_key},
        )
        await session.execute(
            text("""
                UPDATE coverage_cells
                SET geom = ST_Union(geom, ST_SetSRID(ST_GeomFromWKB(:g), 4326))
                WHERE cell_key = :k
            """),
            {"k": cell_key, "g": delta_geom_wkb},
        )

Advisory locks and row locks solve different shapes of the problem. FOR UPDATE ORDER BY id prevents cycles when a transaction touches a set of rows. pg_advisory_xact_lock serialises writers on a single known hotspot (a grid cell, a shared boundary) so they never contend on the row at all — cheaper than letting them deadlock and retry.

Key parameters & options

ParameterWhat it controlsRecommended value
max_attemptsRetry ceiling before giving up5 for ordinary writes; up to 8 for hot rows
base_delay / capExponential backoff window, in secondsbase_delay=0.05, cap=1.0
JitterRandomises retries so contenders do not resynchroniseFull jitter: sleep(random.uniform(0, delay))
Isolation levelREAD COMMITTED deadlocks only on 40P01; REPEATABLE READ/SERIALIZABLE add 40001READ COMMITTED unless a stable snapshot is required
deadlock_timeout (server)How long PostgreSQL waits before running deadlock detectionDefault 1s; lower only on very hot workloads
Lock ordering keyThe column that defines a total order for FOR UPDATEPrimary key id — stable and always indexed
pg_advisory_xact_lock keyIdentifies the hotspot to serialise onA stable integer (grid cell id / hashed boundary key)

Gotchas & failure modes

  • Retrying a non-idempotent write corrupts data. The retry decorator may run the function several times. If the body does parcel_count = parcel_count + 1 outside the locked, atomic scope, a retried transaction double-counts. Fix: make the whole operation idempotent — derive the new state from locked inputs inside the transaction, or key inserts with ON CONFLICT DO NOTHING — so a replay produces the same result. This is the same idempotency requirement flagged for retried bulk writes in managing async transactions for bulk geometry writes.

  • Lock ordering must be consistent across tables too. Ordering rows by id within one table is not enough if transaction A locks parcels then boundaries while B locks boundaries then parcels. Fix: define a global order over tables (e.g. always lock parcels before boundaries) and honour it everywhere.

  • ERROR: deadlock detected (40P01) leaves the transaction aborted. After a deadlock, PostgreSQL has already rolled the losing transaction back; every subsequent statement on that session fails with current transaction is aborted, commands ignored until end of transaction block. Fix: the retry must start a fresh transaction, not continue the aborted one — the decorator does this by re-invoking the whole function.

  • Serialization failures (40001) are not deadlocks but need the same retry. Under REPEATABLE READ or SERIALIZABLE, a concurrent update surfaces as could not serialize access due to concurrent update (40001). It is expected and retryable — include it in RETRYABLE_SQLSTATES, as above.

  • Advisory locks that outlive their transaction. pg_advisory_lock (session-scoped) is not released at COMMIT and, under PgBouncer transaction pooling, leaks onto a backend another client will borrow. Fix: always use the transaction-scoped pg_advisory_xact_lock, which releases automatically — the same SET LOCAL discipline described in the connection pooling guide.

Verification

Reproduce a deadlock deliberately, then confirm the retry recovers. In two psql sessions, lock in opposite order:

-- Session 1                              -- Session 2
BEGIN;                                    BEGIN;
UPDATE parcels SET status='x'             UPDATE parcels SET status='x'
  WHERE id = 17;                            WHERE id = 42;
--                                        --
UPDATE parcels SET status='x'             UPDATE parcels SET status='x'
  WHERE id = 42;   -- waits                 WHERE id = 17;   -- deadlock!
-- one session now shows:
-- ERROR:  deadlock detected
-- SQLSTATE: 40P01

Then run the decorated path under contention and confirm it succeeds without surfacing a 500. Count deadlocks server-side — a healthy service keeps this number low and flat:

SELECT datname, deadlocks FROM pg_stat_database WHERE datname = current_database();
-- If deadlocks climbs steadily, lock ordering is inconsistent somewhere.

Confirm no advisory locks are leaking between requests:

SELECT locktype, objid, pid FROM pg_locks WHERE locktype = 'advisory';
-- Should be empty between transactions when using pg_advisory_xact_lock.

← Back to Async PostGIS Transaction Patterns