[ GUIDE ]

Detecting and fixing Laravel deadlocks

Why deadlocks happen, how to detect them cheaply, how to retry safely, and how to design your code so they stop happening.

QUICK ANSWER

How do I detect and fix Laravel deadlocks?

Catch QueryException with SQLSTATE codes 40001 or 40P01 and retry the transaction with DB::transaction(fn() => ..., 3). For long-term prevention: order lock acquisition consistently, keep transactions short, use SELECT ... FOR UPDATE SKIP LOCKED for queue patterns, and prefer atomic updates over SELECT-then-UPDATE. Monitor deadlock rate and alert on sudden spikes — they often correlate with specific deploys.

Updated · 2026-04-13

What a deadlock looks like in Laravel

Postgres deadlock

text
ERROR: deadlock detected
DETAIL:  Process 5234 waits for ShareLock on transaction 99812; blocked by process 5221.
         Process 5221 waits for ShareLock on transaction 99815; blocked by process 5234.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (0,42) in relation "orders"

Surfaces in Laravel as

php
Illuminate\Database\QueryException
SQLSTATE[40P01]: Deadlock detected: 7 ERROR: deadlock detected
SQL: update "orders" set "status" = ? where "id" = ? and "user_id" = ?

The SQLSTATE codes to care about

Code Meaning Retry?
40001Serialization failure (MySQL/Postgres)Yes
40P01Deadlock (Postgres)Yes
1205Lock wait timeout (MySQL)Yes
1213Deadlock (MySQL)Yes

Retry with exponential backoff

Laravel's DB::transaction has a built-in retry count, but the retry happens immediately. For heavily contended rows, add jittered backoff:

php
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;

function withRetry(callable $tx, int $maxAttempts = 3)
{
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        try {
            return DB::transaction($tx);
        } catch (QueryException $e) {
            $code = $e->errorInfo[0] ?? null;
            $deadlock = in_array($code, ['40001', '40P01']);

            if (!$deadlock || $attempt === $maxAttempts) {
                throw $e;
            }

            // Jittered exponential backoff — 50ms, 100ms, 200ms + random
            usleep((2 ** $attempt) * 50000 + rand(0, 25000));
        }
    }
}

Prevention patterns

1. Consistent lock order

php
// Bad — different paths lock in different orders
DB::transaction(function () {
    $user = User::lockForUpdate()->find($userId);
    $account = Account::lockForUpdate()->find($accountId);
});

DB::transaction(function () {
    $account = Account::lockForUpdate()->find($accountId);
    $user = User::lockForUpdate()->find($userId);
});

// Good — always lock in the same order (alphabetical, ID order, whatever)
DB::transaction(function () use ($userId, $accountId) {
    $user = User::lockForUpdate()->find($userId);
    $account = Account::lockForUpdate()->find($accountId);
});

2. Atomic updates over read-modify-write

php
// Bad — window between SELECT and UPDATE where two workers can conflict
$user = User::find($id);
$user->balance += 10;
$user->save();

// Good — atomic, no lock window
User::where('id', $id)->update(['balance' => DB::raw('balance + 10')]);

// Or with increment
User::where('id', $id)->increment('balance', 10);

3. SKIP LOCKED for queue-like patterns

php
// Claim a pending job atomically — multiple workers won't collide
$job = DB::transaction(function () {
    $row = Job::where('status', 'pending')
        ->orderBy('created_at')
        ->limit(1)
        ->lockForUpdate(skipLocked: true)
        ->first();

    if ($row) {
        $row->update(['status' => 'claimed', 'claimed_at' => now()]);
    }

    return $row;
});

4. Keep transactions short

No external HTTP calls, no slow computations, no user-facing IO inside DB::transaction. A transaction that holds locks for 500ms while waiting on Stripe is an incident waiting to happen.

Monitoring deadlock rate

Aggregate QueryExceptions by SQLSTATE code over time. A sudden jump in 40P01 (Postgres) or 1213 (MySQL) correlates with:

  • A recent deploy introducing a new lock-ordering bug
  • Traffic spike on a hot row (flash sale on a single product)
  • A long-running background job holding locks while user requests pile up
  • Stale query plan picking a table scan instead of an index (bigger lock footprint)

THE EASY WAY

NightOwl groups exceptions by fingerprint — deadlocks surface automatically

QueryException with SQLSTATE 40P01 groups into one issue regardless of which query triggered it. Track count over time, drill in to see which requests hit it most, get alerted on rate spikes.

bash
composer require nightowl/agent
php artisan nightowl:install

From $5/month flat. Data in your PostgreSQL.

Frequently asked questions

What causes deadlocks in Laravel?

Two or more transactions holding locks the other needs. Common Laravel patterns: (1) two requests updating the same rows in different orders within DB::transaction blocks, (2) a background job and a user request competing on the same aggregate (user balance, inventory count), (3) SELECT ... FOR UPDATE against rows that a concurrent UPDATE is already holding, (4) long-running transactions holding locks while other transactions pile up.

How do I detect Laravel deadlocks?

PostgreSQL and MySQL both log deadlocks. In Postgres, they surface as ERROR: deadlock detected with log_min_messages = 'error' (default). In Laravel, the driver throws a QueryException; catch and retry. For production visibility, aggregate QueryExceptions by SQLSTATE code — 40001 (serialization failure) and 40P01 (deadlock in Postgres) are the two you care about.

How do I handle Laravel deadlock retries?

Laravel's DB::transaction accepts a second argument for retry count: DB::transaction(fn() => ..., 3). On deadlock or serialization failure, it rolls back and retries. Retry with backoff to avoid immediately re-colliding. Don't retry non-transient errors — a unique-key violation shouldn't retry indefinitely.

How do I prevent Laravel deadlocks in the first place?

Four patterns: (1) order lock acquisition consistently — always lock parent rows before child rows, (2) keep transactions short — don't do external HTTP calls inside a transaction, (3) use SELECT ... FOR UPDATE SKIP LOCKED for queue-like patterns, (4) batch updates where possible to reduce per-row locking. For aggregate counters (balances, counters), consider an atomic UPDATE ... SET x = x + 1 rather than SELECT-then-UPDATE.

What's the difference between a deadlock and a lock wait timeout?

Deadlock — two transactions cyclically waiting on each other. The DB detects the cycle and kills one automatically. Lock wait timeout — one transaction waits too long for a lock that another transaction is holding (no cycle, just a slow holder). Deadlocks surface as specific SQLSTATE codes; lock wait timeouts as PDOException with 'Lock wait timeout exceeded'. Different causes, different fixes.

Should I alert on Laravel deadlocks?

Yes, but on rate, not absolute count. A few deadlocks per day at high traffic is normal. Alert if the rate jumps 5x above baseline over 10 minutes. Deadlock spikes usually correlate with specific deploys (new code path that grabs locks in a bad order) or traffic patterns (flash sale on a single SKU).

How do I debug a specific Laravel deadlock?

Postgres's pg_stat_activity during the incident and the server log afterwards both help. The Postgres deadlock log line names both queries and the tables involved — that's usually enough to identify the conflicting code paths. Reproduce in staging by running the two transactions concurrently with sleep() in the middle to force the overlap window.

Does Laravel Eloquent cause more deadlocks than raw SQL?

Not inherently, but Eloquent's convenience hides lock behavior. Mass-updating thousands of rows with Model::where(...)->update([...]) holds row-level locks on every row in the set. Loading a relation with with() and then updating it in a loop creates many tiny transactions that can interleave. Awareness of what SQL actually fires is the fix.

PRICING

Flat pricing. No event caps. No per-seat fees.

14-day free trial, no credit card. Your PostgreSQL, your data.

HOBBY

$5 /month

1 app · 14 days lookback · all Laravel events

TEAM

$15 /month

Up to 3 connected apps · unlimited environments · all Laravel events

AGENCY

$69 /month

Unlimited apps · unlimited agent instances · same flat rate at any traffic

Related