Odoo Development
Could Not Serialize Access Due to Concurrent Update
This error is PostgreSQL refusing to let two transactions commit incompatible changes to the same row. Odoo expects it, catches it and retries five times before you ever see it, which is why the message reaching a user means something more specific than a collision.
Written by Tayyab RasheedOdoo 19 Certified, Technical Consultant
Part of our guide to Odoo Development
The exact error message
The string PostgreSQL emits is this, and only this:
ERROR: could not serialize access due to concurrent update
It carries SQLSTATE 40001, which psycopg2 raises as psycopg2.errors.SerializationFailure, a subclass of TransactionRollbackError.
Many people search for it as "could not serialize access to table due to concurrent update". PostgreSQL has no "to table" form of this message, so that is a paraphrase written from memory rather than a real log line. Match your own logs against the shorter string.
Three messages in this family look alike and mean different things:
could not serialize access due to concurrent updateis this one, reproduced below. Another transaction updated the same row and committed first.could not serialize access due to concurrent deletemeans the row you were updating had been deleted, and that delete committed first.could not serialize access due to read/write dependencies among transactionsis raised only at theSERIALIZABLEisolation level, which Odoo does not use. Seeing it means something set the level explicitly.
What the error means
Odoo runs every database connection at the REPEATABLE READ isolation level. A transaction at that level sees a consistent snapshot of the database as it stood when it took its first read. If it then updates a row that another transaction has modified and committed since that snapshot, PostgreSQL has no correct way to reconcile the two versions, so it aborts the later one.
Two consequences matter while you are debugging.
Nothing is half-written. The entire transaction rolls back, so there is no partial state to clean up.
The other transaction wins and yours is discarded. In the reproduction below the row keeps the competing session's value, and the failed session's write is simply gone. If the failing operation was a user pressing save, that save did not happen.
Why Odoo hits this and most applications do not
The message is PostgreSQL's, not Odoo's: searching the whole Odoo source tree for the string returns nothing. PostgreSQL raises it from ExecUpdate, in src/backend/executor/nodeModifyTable.c. The line number within that file moves between releases, so match on the file and function rather than on a line.
What Odoo contributes is the isolation level. odoo/sql_db.py calls set_isolation_level(ISOLATION_LEVEL_REPEATABLE_READ) on every connection it opens. Most applications leave the PostgreSQL default of READ COMMITTED, where an UPDATE that meets a concurrently updated row waits for the other transaction and then re-evaluates its condition instead of failing. That single difference is why the error is routine in Odoo and close to unknown elsewhere.
The Cursor class docstring gives the reasoning: Odoo locks the transactions "highly likely to provoke concurrent updates, such as stock reservations or document sequences updates" itself, so it wants snapshot isolation without the rollback heuristics SERIALIZABLE adds.
What Odoo does before you ever see it
This is the part most explanations of the error miss, and it changes the diagnosis completely.
Request handling is wrapped in a retrying() function in odoo/service/model.py. On a concurrency failure it does not surface the error. It rolls back the cursor, resets the transaction and discards registry changes so the call replays from a clean state, reloads the session, rewinds every uploaded file, sleeps random.uniform(0.0, 2 ** tryno) seconds so colliding requests do not retry in lockstep, and calls the request again.
MAX_TRIES_ON_CONCURRENCY_FAILURE is 5 in Odoo 17, 18 and 19: five attempts in total, with the fifth allowed no further retry. When that one fails, Odoo logs maximum number of tries reached! at INFO level and re-raises.
Adding the upper bounds of the four sleeps, 2 plus 4 plus 8 plus 16, gives a ceiling of thirty seconds of waiting before the error can reach a user. That figure is arithmetic from the backoff formula, not a measured timing.
So if a user saw this message, the retry budget was exhausted. The same row stayed contended across five attempts spread over seconds, which means a hotspot rather than bad luck.
Retry behaviour by Odoo version
All three supported versions retry. What differs is how each one recognises the failure: Odoo 17 tests the SQLSTATE carried on the exception, 18 tests the exception class, and 19 tests the exception class plus a second branch for ConcurrencyError, an Odoo exception that does not exist in 18 or 17.
| Odoo version | Detection mechanism | Retried? | Retry condition | Important caveat |
|---|---|---|---|---|
| 19 | Exception class, plus Odoo's own ConcurrencyError | Yes, up to 5 attempts | isinstance(exc, PG_CONCURRENCY_EXCEPTIONS_TO_RETRY) at service/model.py:216, or isinstance(exc, ConcurrencyError) at :218 | ConcurrencyError exists only in 19 (odoo/exceptions.py:128). The except clause catches three types (:192). |
| 18 | Exception class | Yes, up to 5 attempts | isinstance(exc, PG_CONCURRENCY_EXCEPTIONS_TO_RETRY) at service/model.py:177 | The except clause catches two types (:161). |
| 17 | SQLSTATE, via exc.pgcode | Yes, up to 5 attempts | exc.pgcode not in PG_CONCURRENCY_ERRORS_TO_RETRY at service/model.py:173 | Odoo 17 never defines the exception-class tuple; pgcode is the only mechanism (:23). |
All three versions retry the same three conditions, 55P03 lock not available, 40001 serialization failure and 40P01 deadlock detected, with the same cap of five attempts and the same random.uniform(0.0, 2 ** tryno) backoff. What changes between versions is how the failure is recognised, not what happens to it. For an ordinary serialization failure the outcome is identical on 17, 18 and 19.
What Odoo retries, and what it does not
The three PostgreSQL concurrency conditions above are retried wherever they arise during request handling. Two failures that reach the same code path are not.
Not retried: constraint violations. retrying() catches IntegrityError in the same except clause but never retries it. It resolves the offending table to a model, formats the message and raises a ValidationError straight away. That raise sits above the retry decision in the code path, so an IntegrityError cannot reach it: a duplicate key is a data problem, and replaying it would only fail again.
Not retried: a request carrying a non-seekable upload. Before deciding anything, retrying() rewinds the files on the request. If one reports that it is not seekable, Odoo cannot replay the request faithfully, so it raises RuntimeError("Cannot retry request on input file ... after serialization failure") instead. An upload endpoint that fails under concurrency while everything else recovers is usually this, not the underlying collision.
Reproducing it safely
Two psql sessions against a scratch database are enough, and none of it needs Odoo. Set up a single row:
CREATE TABLE t (id int primary key, v int); INSERT INTO t VALUES (1, 0);
Session A goes first. The pg_sleep stands in for slow work:
BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT v FROM t WHERE id = 1; -- takes the snapshot SELECT pg_sleep(3); UPDATE t SET v = 1 WHERE id = 1; COMMIT;
Session B starts about a second later, while A is still sleeping, and commits normally:
BEGIN ISOLATION LEVEL REPEATABLE READ; UPDATE t SET v = 2 WHERE id = 1; COMMIT;
Session A then produces:
BEGIN v --- 0 (1 row) ERROR: could not serialize access due to concurrent update ROLLBACK
Afterwards the row holds 2, session B's value. Session A's update is gone.
Two details matter. REPEATABLE READ has to be requested explicitly, because psql leaves the PostgreSQL default of READ COMMITTED in place whereas Odoo sets it on every connection, which is the whole reason this is an Odoo problem. And \set VERBOSITY verbose before the failing statement changes the output to ERROR: 40001: could not serialize access due to concurrent update plus a LOCATION line naming ExecUpdate and nodeModifyTable.c, which is how to confirm the SQLSTATE yourself.
The four hotspots, in the order worth checking
1. A "No gap" sequence
This is the most common cause in production and the easiest to fix.
Odoo sequences have two implementations, Standard and No gap. The Standard implementation uses a PostgreSQL sequence, which is designed for concurrency and does not block. The No gap implementation cannot, because guaranteeing unbroken numbering means nobody else may take a number until the current transaction finishes.
To do that, _update_nogap in ir_sequence.py runs:
SELECT number_next FROM ir_sequence WHERE id=%s FOR UPDATE NOWAIT
NOWAIT means the second transaction does not queue, it fails immediately with LOCK_NOT_AVAILABLE. Every document that draws from that sequence now serialises through one row.
At low volume this is invisible. At a few invoices a second, or during an import, it is a wall. If a regulator genuinely requires gapless numbering, keep it and reduce the concurrency around it. If nobody requires it, and frequently nobody does, switch that sequence to Standard.
2. A hot parent record
Some records are written by many transactions at once by their nature. A product whose stock everyone is reserving. A single order accumulating lines from a fast process. A partner being updated by an integration while a user edits it.
The fix is usually to stop writing to the parent. A stored computed field on a parent that depends on its children means every child write becomes a parent write, and every parent write is a collision candidate. Ask whether that field really needs to be stored, and whether a related field would do the same job without the write.
The same applies to constraints that read across siblings: a Python constraint on a child that re-reads the whole parent turns one write into a wide read on every save.
3. A cron job overlapping live users
Scheduled jobs run against the same data users are working in. A job that touches many records for a long time is holding a large collision surface.
Odoo's cron code acknowledges this directly. The docstring on the job acquisition helper notes that it may raise a serialisation failure when another worker has taken the job, and that the correct response is to roll back and move on to the next one.
Move heavy jobs outside working hours, keep their transactions short, and process in batches that commit rather than in one long sweep. Scheduled actions covers how to structure this.
4. Custom code writing inside a loop
The classic shape:
for line in order.order_line:
line.order_id.write({'some_total': ...})Every iteration writes the same parent row, in the same transaction, and multiplies the window in which another process can collide. Compute once and write once to the whole recordset.
This is the same discipline that keeps ORM code fast in general, covered in coding standards and computed fields.
Fix patterns
If you write code, remove the write rather than protect it. Compute once over a recordset and write once instead of writing per record in a loop. Keep transactions short, because the transaction is the collision window, and commit in batches inside a long job rather than holding one sweep open. Where a section genuinely has to serialise, take the lock deliberately, the way the no-gap sequence and cron acquisition do.
If you integrate, the cause is usually volume rather than logic. A client that pushes records into Odoo in parallel funnels all of them onto the same sequence row or the same parent. Reduce its parallelism before changing any Odoo code, spread work across different documents rather than across threads on one document, and give it its own retry with backoff, so the few failures that survive Odoo's five attempts do not become lost records.
If you administer the system, the fix is scheduling rather than code, and the module-update case has its own answer below. Adding workers is the instinct to resist, because the contention is on one row and more workers only reach it faster.
Anti-patterns
- Raising the retry count. Five attempts with exponential backoff already absorb incidental collision, and up to thirty seconds of retrying has happened before the error surfaces. More attempts hold a connection and its locks open longer, adding to the contention they are meant to survive.
- Catching and ignoring the exception. The transaction is already rolled back when the error is raised, so swallowing it does not complete the work. It only stops anyone finding out that the work did not happen.
- Wrapping ORM calls in your own retry loop. Between attempts
retrying()rolls back the cursor, resets the transaction and the registry, reloads the session and rewinds uploads. A hand-written loop skips all of that and replays against state the failure invalidated. - Changing the isolation level.
SERIALIZABLEadds the rollback heuristics Odoo set out to avoid, andREAD COMMITTEDremoves the repeatable reads the cursor cache is documented as depending on. - Treating it as an infrastructure problem. Faster hardware reaches the same contended row more often per second, not less.
Diagnostic checklist
Work through these in order.
- Search the log for the exhaustion line.
maximum number of tries reached!is logged at INFO on the final failure. Retry lines without it mean the contention is being absorbed. - Read the failing statement out of the traceback. The table named in it is the hotspot, and usually identifies which of the four causes you have.
- Confirm the SQLSTATE is
40001.55P03is aNOWAITlock already held, which points at a no-gap sequence or another explicit lock rather than a snapshot conflict.40P01is a deadlock, a different problem with a different fix. - Compare the timestamps against your scheduled actions. Overlap with a cron is the answer more often than people expect.
- List the sequences using No gap and check whether any of them sits on a high-volume document.
- Only then read the code, knowing the table, the timing and the concurrency source.
If the contention appeared after an upgrade rather than after a code change, check what moved. A stored compute or a constraint that was cheap on the old version can behave differently once field definitions change, which is the kind of thing what actually breaks in an upgrade and migration scripts both touch on.
If this is happening on a live system and the hotspot is not obvious from the log, it is worth an Odoo health check. Contention of this kind is usually one specific piece of configuration or one loop, and it is far quicker to find with someone reading the log alongside the code than by changing things and waiting.
FAQ
Questions, answered.
What does could not serialize access due to concurrent update mean in Odoo?
It means two transactions tried to change the same database row, and PostgreSQL cancelled the second one. Odoo runs every cursor at the REPEATABLE READ isolation level, where each transaction sees a snapshot of the database taken when it started. If your transaction updates a row that another transaction has already modified and committed since that snapshot, PostgreSQL cannot merge them, so it aborts yours.
This is normal database behaviour, not corruption and not a bug. Nothing is half-written, because the whole transaction rolls back.
What matters is that Odoo already handles it. The retrying() wrapper in odoo/service/model.py catches serialisation failures, rolls back, waits, and calls the request again up to five times. So an ordinary two-users-clicked-at-once collision is invisible.
Expert tip. If a user actually sees this message, the retries were exhausted. That is not a random collision, it is sustained contention on one row, and the fix is to find the hotspot rather than to retry harder.
How do I fix the concurrent update error in Odoo?
Find which row is contended, then remove the contention. Retrying harder does not help, because Odoo has already retried five times before the error surfaced.
Work through the four common hotspots in order:
1. A "No gap" sequence. Odoo's no-gap implementation takes SELECT ... FOR UPDATE NOWAIT on the sequence row, so every document creation serialises on it. Under load this is the single most common cause. Switch to the Standard implementation unless a regulator genuinely requires unbroken numbering.
2. A hot parent record. Stock reservations against one product, or many lines writing to one order, funnel every write onto the same row.
3. A cron overlapping a user. A scheduled job holding rows while users work on them collides by design.
4. Custom code writing in a loop. Per-record writes inside a loop multiply the collision window. Batch the recordset and write once.
Common mistakes
- Raising the retry count instead of removing the hotspot
- Adding a
try/exceptthat swallows the error, which hides data loss rather than preventing it - Assuming it is a hardware or connection problem
Does Odoo retry the concurrent update error automatically?
Yes. Odoo's retrying() function wraps request handling and retries on three PostgreSQL error classes: serialisation failure, lock not available, and deadlock detected.
The behaviour is specific and identical in Odoo 17, 18 and 19:
- Up to five attempts in total, set by
MAX_TRIES_ON_CONCURRENCY_FAILURE - Between attempts the transaction is rolled back, the transaction and registry changes are reset, and the session is reloaded
- It waits a random interval with exponential backoff,
random.uniform(0.0, 2 ** attempt), so retries spread out rather than colliding again immediately - Uploaded files on the request are rewound so the retry sees them from the start
- When the last attempt fails it logs "maximum number of tries reached" and re-raises
That log line is the one to search for. It tells you the retry budget was spent, which distinguishes real contention from noise.
Expert tip. If a request uploads a non-seekable file, Odoo cannot rewind it and raises "Cannot retry request on input file" instead of retrying. A file upload endpoint that fails under concurrency is usually this, not the underlying collision.
Why does this error happen during an Odoo module update or on Odoo.sh?
Because a module update rewrites shared configuration rows while something else is still touching them. The usual something else is a cron job, a second worker, or a website request loading assets.
During an update Odoo writes to ir_module_module, view definitions, and asset bundles. Those are exactly the rows every other process reads and occasionally writes. On a multi-worker deployment or a build platform, two processes can easily meet there.
Practical measures:
- Stop or pause cron jobs during the update, which removes the most common competitor
- Update with a single worker where the deployment allows it
- Avoid triggering an update while a build or a second update is already running
Expert tip. If the failing statement involves asset bundles, the collision is usually two workers regenerating assets at once after a restart. Warm the assets with a single request before opening the instance to traffic and it stops happening.
Keep reading
More on Odoo Development.
The full guide, plus the other articles in this cluster.
With CODEerts
Need something built properly?
We are certified Odoo partners. We build custom Odoo modules that extend the framework rather than fight it, and we publish our own apps on the Odoo Store.
See how we can helpBook a callReady to make Odoo work the way your business does?
Book a free callCODEerts is a team of certified Odoo partners and full-stack engineers. We implement, customise and support Odoo ERP, then build the software around it.