Skip to main content

Odoo PostgreSQL Deadlock During Stock Move — Fix Guide 2026

Odoo deadlock detected during inventory stock moves in 17/18/19? Fix concurrent reservations, lot picking, and quant updates today in production safely.

E
ECOSIRE Research and Development Team
|May 4, 20266 min read1.2k Words|

Two Odoo users confirm pickings at almost the same moment. One succeeds. The other gets:

Two Odoo users confirm pickings at almost the same moment. One succeeds. The other gets:

psycopg2.errors.DeadlockDetected:
deadlock detected
DETAIL: Process 12345 waits for ShareLock on transaction 67890; blocked by process 12346.
        Process 12346 waits for ShareLock on transaction 67891; blocked by process 12345.
HINT: See server log for query details.

The retry sometimes succeeds, sometimes fails again. This is the most operationally painful Odoo concurrency issue — it shows up in inventory, accounting, and any model with shared parent rows. Applies to Odoo 17.0/18.0/19.0.

Quick Fix

Enable Odoo's automatic deadlock retry by ensuring RegistryManager.lock_request_retries is set in the controller. For inventory specifically, the retry is usually built-in but may need:

# odoo.conf
db_template = template1
log_level = warn   # so deadlock messages show up

For your custom code, wrap risky operations in a retry loop:

from odoo import api
from psycopg2.errors import DeadlockDetected
import time
import random

def _process_with_retry(self, max_attempts=3):
    for attempt in range(max_attempts):
        try:
            return self._actual_process()
        except DeadlockDetected:
            if attempt == max_attempts - 1:
                raise
            self.env.cr.rollback()
            time.sleep(0.1 * (2 ** attempt) + random.random() * 0.05)

Exponential backoff with jitter is the standard pattern for deadlock retry.

Why This Happens

PostgreSQL deadlocks happen when two transactions hold locks the other needs. In Odoo stock moves, the locking pattern is:

  1. Transaction A locks stock.move row 123.
  2. Transaction A goes to lock stock.quant row 456 (the source quant).
  3. Transaction B (concurrent) locked stock.quant row 456 first.
  4. Transaction B wants stock.move row 123 next.
  5. Both wait for each other → deadlock.

The five common scenarios in Odoo:

  1. Concurrent reservations on the same product. Two users reserving stock for the same product locking the same stock.quant rows in different orders.
  2. Picking confirmation simultaneously. Two users confirming different pickings that share a source location.
  3. Crons running alongside user actions. A reservation cron and an interactive picking colliding.
  4. Custom code that locks rows in non-canonical order. Always-lock-children-before-parents is the convention; reversing it across transactions creates deadlocks.
  5. account.move.line posting — accounting moves that touch shared partner balances or analytic accounts in interleaved order.

Step-by-Step Diagnosis

1. Enable detailed lock logging:

# postgresql.conf
log_lock_waits = on
deadlock_timeout = 1s
log_min_duration_statement = 100

Reload PostgreSQL. Reproduce the deadlock. Look at the full lock-wait detail in the log.

2. Identify the two queries that deadlocked. PostgreSQL logs both queries when reporting a deadlock:

ERROR: deadlock detected
DETAIL: Process 12345 waits ... blocked by process 12346
... statement: UPDATE stock_quant SET reserved_quantity = ...
... statement: UPDATE stock_move SET state = 'done' ...

Look at the queries — one is locking quants then moves, the other moves then quants.

3. Trace back to Odoo source. For each query, find the Python code that issued it. Stock module functions like _action_assign, _action_done, _quant_tasks are common origins.

4. Check pg_locks during a stress test:

SELECT pid, locktype, relation::regclass, mode, granted
FROM   pg_locks
ORDER  BY pid;

Run this in a loop while reproducing. You will see the lock acquisition order each transaction takes.

5. Look for custom code in stock.move overrides. git grep "_action_done\|_action_assign\|_apply_putaway_strategy" custom/ finds custom inventory logic. Each is a candidate for non-canonical lock order.

Permanent Fix

Lock in canonical order. The Odoo convention is: parents before children, lower id before higher id, primary models before derived. For stock:

# RIGHT — always lock the move first, then the quant
move = self.env['stock.move'].browse(move_id)
self.env.cr.execute("SELECT * FROM stock_move WHERE id = %s FOR UPDATE", (move_id,))

quant = self.env['stock.quant'].browse(quant_id)
self.env.cr.execute("SELECT * FROM stock_quant WHERE id = %s FOR UPDATE", (quant_id,))

Sort lock acquisition by id when locking many rows:

def _reserve_quants(self, quants):
    sorted_quants = quants.sorted(key=lambda q: q.id)
    for quant in sorted_quants:
        self.env.cr.execute("SELECT * FROM stock_quant WHERE id = %s FOR UPDATE", (quant.id,))
        # ... reserve logic

Add deadlock retry to risky code paths:

from psycopg2.errors import DeadlockDetected, SerializationFailure

def _process_picking(self, picking):
    for attempt in range(3):
        try:
            with self.env.cr.savepoint():
                picking._action_done()
            return
        except (DeadlockDetected, SerializationFailure):
            if attempt == 2:
                raise
            self.env.cr.rollback()
            time.sleep(0.1 + random.random() * 0.1)

Three retries with jittered backoff handles 99 percent of transient deadlocks.

Use shorter transactions. Long transactions hold locks longer, increasing deadlock probability. Commit between logical units:

for picking in pickings:
    with self.env.cr.savepoint():
        picking._action_done()
    self.env.cr.commit()    # release locks before processing the next

Use SELECT FOR UPDATE SKIP LOCKED for queue-like patterns. When multiple workers process the same queue:

SELECT id FROM stock_move
WHERE  state = 'confirmed' AND product_id = %s
FOR UPDATE SKIP LOCKED
LIMIT 10;

SKIP LOCKED makes each worker grab a different subset rather than waiting and deadlocking.

For chronic deadlocks on accounting moves, install OCA's queue_job and process journal entries through the queue. Single-threaded queue processing eliminates concurrency entirely; throughput drops, but consistency is bulletproof.

How to Prevent It

  • Always retry on deadlock. Make this a code-review rule. Any code path that touches stock.quant, account.move.line, or any shared inventory/financial row gets a retry decorator.
  • Lock in canonical order. A team rule like "always sort by id before locking" prevents cross-transaction order disagreements.
  • Short transactions. Commit between logical units of work. Long transactions are deadlock-prone.
  • Test under concurrency. Run two simulated users doing the same picking confirmation in parallel. If it deadlocks consistently, fix the lock order. If it deadlocks intermittently, add retry.
  • SKIP LOCKED for queues. Whenever multiple workers compete for a queue of work, SKIP LOCKED is the right idiom.
  • Monitor deadlock rate. PostgreSQL's pg_stat_database.deadlocks counter shows lifetime deadlocks. Spikes indicate a regression.

Frequently Asked Questions

Why does PostgreSQL not just queue the second transaction?

Because that would be a deadlock, just slower. PostgreSQL detects the cycle (two transactions waiting on each other) and aborts one — the alternative is hanging forever. The aborted transaction can retry; the surviving one proceeds.

Should I use SERIALIZABLE isolation level?

It would convert deadlocks into serialization failures (which are also retryable), but at a steep performance cost on Odoo's workload. The default READ COMMITTED with explicit FOR UPDATE and retry is the right choice.

Can I disable deadlock detection?

No, and you would not want to. Disabling deadlock detection means transactions hang forever. The fix is to design code so deadlocks are rare and to retry the rare ones gracefully.

Why do my deadlocks only happen in production?

Production has more concurrent users. A code path that locks in non-canonical order may never deadlock with one user but always deadlock with 50. Test under concurrency in staging before declaring a fix complete.


Need help with a tricky Odoo error? ECOSIRE's Odoo experts have shipped 215+ modules — get expert help.

E

Written by

ECOSIRE Team

Technical Writing

The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.

ECOSIRE

Transform Your Business with Odoo ERP

Expert Odoo implementation, customization, and support to streamline your operations.