Error Codes
ScramDB speaks the PostgreSQL wire protocol and PostgreSQL's own SQLSTATE codes, so most of a client's cluster error handling is code it already has. The whole cluster failure surface is four SQLSTATEs, produced by one function in the engine, plus one connection-level case that carries no SQLSTATE at all because no server ever got the chance to send one. 40001 is the same serialization_failure every PostgreSQL driver and ORM already retries; clustering just adds new reasons the cluster can raise it. 57P01 and 57P03 are PostgreSQL's own admin_shutdown and cannot_connect_now, both plain reconnect cases. The one genuinely new thing a distributed engine has to hand a client is 08007: an outcome that is honestly unresolved rather than known-good or known-bad. This page exists mainly to make that one case impossible to get wrong.
One place in the engine owns this entire mapping. Every cluster-shaped failure is matched there explicitly; a new failure variant does not compile until it has a contracted code, and nothing cluster-shaped falls through to a generic internal error. Read the table below once and a retry loop built from it needs no other input.
The master table
| SQLSTATE | PostgreSQL name | Retry class | What actually happened | What the client must do |
|---|---|---|---|---|
40001 | serialization_failure | Definite | The transaction left no effect anywhere in the cluster. | Re-execute the transaction. Safe unconditionally. |
08007 | transaction_resolution_unknown | InDoubt | The write was accepted and may still commit; its outcome was never observed. | Reconnect, verify whether it applied, and retry only if the statement is idempotent. |
57P01 | admin_shutdown | Unavailable | This node is draining or shutting down; the statement never reached the cluster. | Reconnect to a different node. |
57P03 | cannot_connect_now | Unavailable | The cluster has not finished forming yet, a cluster table's shard assignment has not reached this node yet, or this node's copy could not catch up in time for a read; the statement never reached it, or read nothing. | Back off and reconnect. |
| (none) | driver-level connection error before COMMIT was sent | Unavailable, handled like 57P01 | The connection died before any server could answer, and the transaction had not asked to commit. Nothing was decided. | Reconnect to a different node and re-execute. |
| (none) | driver-level connection error after COMMIT (or an autocommit write) was sent | InDoubt, handled like 08007 | The connection died after the commit was asked for; the commit may have happened. | Reconnect, verify whether it applied, and retry only if the statement is idempotent. |
40P01 | deadlock_detected | Definite | Two or more transactions on different nodes each waited on a lock the other held. ScramDB detected the cycle cluster-wide and aborted one of them; the aborted transaction left no effect. | Re-execute the aborted transaction. |
42704 | undefined_object | not a cluster failure | SET, SHOW or RESET named a configuration parameter that does not exist. | Fix the parameter name. Retrying will not help. |
54001 | statement_too_complex | not a cluster failure | A statement's own expression tree nests deeper than can be safely evaluated, or a function or procedure recurses too deeply. | Rewrite the statement or the routine to nest shallower. Retrying unchanged will not help. |
22003 | numeric_value_out_of_range | not a cluster failure | An arithmetic result does not fit its type: an integer, bigint or smallint out of range, a floating-point overflow, or a NUMERIC result past 38 significant digits. | Fix the query or the data. Retrying unchanged will not help. |
57014 | query_canceled | not a cluster failure | The statement was cancelled: statement_timeout elapsed, or a client sent PostgreSQL's CancelRequest or called pg_cancel_backend(). | The session is still usable. Re-issue the statement if it should still run. |
42883 | undefined_function | not a cluster failure | LIKE or ILIKE was used against an operand that is not text, such as an integer or an enum column. | Cast the operand to text first. Retrying unchanged will not help. |
Three retry classes cover the whole table:
- Definite: the transaction provably left no effect. A blind re-execute is unconditionally safe, exactly what PostgreSQL's own
40001already trains every ORM to do. - InDoubt: the write may still commit. It was accepted somewhere and its outcome was never confirmed, so re-executing blindly risks applying it twice.
- Unavailable: the statement never entered the cluster at all. Nothing is in doubt because nothing was ever attempted; the right move is to reconnect, possibly elsewhere, rather than retry in place.
How the contract holds together
One function, no exceptions
Every cluster-shaped failure the engine's driver layer can produce is classified by one function. It matches each failure case explicitly: add a new one and the match stops compiling until it is given a contracted code. That is deliberate, and it is the only mechanism that keeps a cluster failure from reaching the generic XX000 internal-error fallback or getting a SQLSTATE invented for it at the call site that happened to hit it.
The engine holds itself to the same rule internally. Its own bounded internal retries, for instance a bulk COPY riding out a leader election mid-load, only ever fire for a Definite failure, never for an in-doubt one; retrying an in-doubt failure inside the engine would double-apply exactly as a client's blind retry would.
InDoubt and Definite are never merged, and no code path collapses one into the other. 40001 means the transaction provably left no effect: re-executing it is always safe. 08007 means the opposite is possible: the write was accepted and may already have committed. Treating an in-doubt outcome as a definite one and blindly retrying is exactly how a non-idempotent write gets applied twice. This is the one distinction on this page that actually matters; the rest is bookkeeping.
Do not parse the message to decide whether to retry
The message text always names the real cause, even when several causes share one SQLSTATE, so a log line still tells a human whether a given 40001 was a write conflict, an election, or a stale schema epoch. It is not, however, a stable API: retry logic should switch on the SQLSTATE alone. If a situation ever exists where the SQLSTATE is not enough to decide correctly, that is a bug in ScramDB worth reporting, not a reason to start matching strings.
Where the hint text actually is
Every cluster failure carries a one-sentence hint alongside its message ("re-execute it", "reconnect and verify", and so on). On the wire that hint is not a separate structured field: it arrives appended to the message text itself, as a trailing HINT: <text> line under the same SQLSTATE. A client that only reads the raw message string still gets the hint; there is nowhere else to look for it.
A coded error keeps its SQLSTATE through COMMIT
An error raised while a COMMIT is being sent still carries its original SQLSTATE, wrapped in a COMMIT failed: ... message, rather than losing its code and arriving as a generic failure. PREPARE TRANSACTION and COMMIT PREPARED keep their own error's SQLSTATE the same way, without that added wrapper text. A client that switches on the SQLSTATE alone, per the rule above, sees the same code whichever of the three failed.
SQLSTATE 40001: serialization_failure
Hint, fixed for every cause below except the last: the transaction left no effect; re-execute it
Every one of these is Definite: whatever happened, it happened before anything committed, so re-executing the transaction from the start is always correct. The contract folds seven distinct causes into this one code; the message always says which one it was.
| Cause | Message shape |
|---|---|
| The commit lost to a transaction it conflicted with: another transaction committed a change to a row this one wrote or read, or the two committed at the same moment and this one, the younger, gave way | could not serialize access due to concurrent update |
| A row the transaction read was changed by another transaction's commit before this one's commit could be decided | could not serialize access due to read/write dependencies among transactions |
| A table the transaction wrote or read changed its placement while the transaction ran | the table's placement changed while the transaction ran; retry the transaction |
| The cluster lost the transaction's commit state before its commit was decided | the transaction's commit state was lost before any vote; retry the transaction |
| A read position could not be chosen because a shard group has no leader | the snapshot could not be taken because a shard group has no leader ({detail}); retry |
| Stale schema epoch (the statement planned against a superseded catalog) | schema changed while this statement was planning ({detail}); re-execute so it re-plans against the current catalog |
A learner_read = stale read whose replica has fallen behind max_staleness, or whose lag cannot be measured yet | bounded-staleness read refused: group {group} is {age}ms behind, past the {bound}ms staleness bound, or bounded-staleness read refused: replica lag is not measurable yet (nothing applied on a required group) |
{detail} is filled in with the specifics of what actually happened. None of that changes the SQLSTATE, only the message. The staleness-bound row carries its own hint instead of the fixed one above: retry, or use learner_read = 'strong'. See Read freshness and routing for the freshness rule behind it.
Two conflicting commits never wait for each other in a way that could deadlock: when they meet, the younger one gives way at once, so the conflict is reported as 40001, never as 40P01 (deadlock_detected), which ScramDB raises only for a real cycle among waiting locks. A transaction that keeps losing is retried older each time on the same session, so it eventually wins.
40001 is not only a cluster code: a single node running REPEATABLE READ or SERIALIZABLE raises the exact same SQLSTATE, with PostgreSQL's own wording, whenever a transaction's snapshot has gone stale by the time it tries to write. A REPEATABLE READ transaction that would overwrite a row a concurrent transaction already committed a newer version of gets could not serialize access due to concurrent update; a SERIALIZABLE transaction refuses with could not serialize access due to read/write dependencies among transactions whenever any table it read was written by another transaction after its own snapshot was taken. That check is conservative read validation, not cycle detection: it refuses strictly more than a real write-skew detector would, never less, so a SERIALIZABLE transaction can be aborted here even without a genuine cycle. Both are Definite in exactly the sense above: the aborted transaction left no effect, so re-executing it from the start is safe. Neither is decided by classify, which only sees cluster-shaped failures; this is the same single-node MVCC contract every PostgreSQL client already retries on.
A second writer on a row a REPEATABLE READ or SERIALIZABLE transaction is already touching does not fail immediately: it waits for the row's holder, bounded by lock_timeout. If the wait times out, the second writer gets 55P03 (lock_not_available). If the holder commits first, the waiter then gets 40001 as described above, never a silently lost update. A wait that would deadlock (two transactions each waiting on a row the other holds) is broken after deadlock_timeout with 40P01, against the younger of the two transactions by start timestamp.
A row of a distributed table is never locked, so none of that waiting happens there: the second writer goes ahead, and whichever of the two commits second gets 40001 at its COMMIT, at every isolation level, READ COMMITTED included. Neither 55P03 nor 40P01 comes from a row of a distributed table. See Row locks on distributed tables.
SQLSTATE 08007: transaction_resolution_unknown
Hint, fixed for every cause below: the transaction's outcome is UNKNOWN and it may still commit; reconnect and verify before any retry - a blind retry can double-apply
Every one of these is InDoubt: the commit was sent, and this connection could not learn what became of it. The cluster itself always decides the transaction, committed or not, once its shard groups can be reached again; only this client does not know which.
| Cause | Message shape |
|---|---|
The commit's answer did not come within [cluster.transactions] commit_answer_timeout (default 10 seconds), and the node's question about the transaction's outcome was not answered within outcome_query_timeout (default 1 second), for example while a shard group's replicas cannot reach each other | the transaction's outcome is not known yet; it may still commit |
| The node's commit protocol stopped while the commit was in flight | the transaction's outcome is not known yet; it may still commit |
A cancel or a statement_timeout that arrives while a COMMIT waits for its answer does not cut the wait short: once the commit is sent, the client is told its outcome (or 08007), never a cancellation of a commit that may have happened. The same holds for COMMIT PREPARED.
For a non-idempotent effect (a payment, an email, a call to an external API), the correct response to 08007 is never a bare retry counter: reconnect, verify whether the write actually landed, and only replay it if it is safe to apply twice or is itself protected by a dedupe key.
SQLSTATE 57P01: admin_shutdown
Hint: this node is shutting down; reconnect to another node
The node the client was talking to is draining, whether from an operator-initiated drain or an ordinary shutdown, or its commit protocol stopped. The statement never reached the cluster, so nothing needs undoing; the fix is to reconnect somewhere else. Message shape: node is draining: {detail}. See Failover for what a client experiences end to end when a node goes away.
A node that can never start a transaction (the cluster has no node number left to give it, or the file that keeps its transaction numbers unique cannot be read or written) answers with the same code and hint connect to another node, message shape this node cannot run transactions: {detail}. It keeps refusing until an operator fixes the cause, so reconnect elsewhere.
SQLSTATE 57P03: cannot_connect_now
Hint: the cluster is not serving yet; back off and reconnect
The cluster, specifically its own metadata group, has not finished forming yet, or this node has not yet been given the number its transactions are named by. Both are startup-time conditions, not something a running cluster produces once it is up. Message shape: cluster is not ready: {detail}.
The same code and message shape cover one more case, at any time a cluster is otherwise healthy: a statement named a cluster table whose shard assignment has not reached this node's own view of the cluster's placement yet - just after the table was created elsewhere, or just after this node restarted and is still catching up with the cluster's metadata group. The statement waits for the assignment itself, for up to [cluster.transactions] commit_answer_timeout (10 seconds by default), and then runs as usual; it answers this way only when the assignment did not arrive in that time, which means this node cannot reach the cluster's metadata group. Detail: table "{name}" is not placed on this node yet; retry the statement. Nothing was read or written; retry the statement, or connect to another node. For COMMIT PREPARED the prepared transaction then stays prepared, and running COMMIT PREPARED again commits it.
A running cluster answers 57P03 for one more reason: this node's copy could not catch up in time for a read. A read waits until this node's copy of every shard group it reads has applied what the read needs; when that does not happen within the read's wait (about ten seconds with the default settings), the read is refused with hint retry the statement, or connect to another node and message this node's replica of shard group {n} is too far behind to serve the read. Nothing was read. Retry, or reconnect to another node.
No SQLSTATE: the connection just died
A dead socket is not a database error and carries no SQLSTATE, because no server ever got the chance to send one. The client's driver raises its own transport-level exception instead. This is, in practice, the single most common thing a client sees when a node is killed: a node that dies closes its sockets before it can answer anything.
What it means depends on when the connection died. Before your transaction sent COMMIT (or, outside a transaction block, before its writing statement was sent), nothing was decided: treat it exactly like 57P01, reconnect to a different node and re-execute. After COMMIT was sent, the outcome is unknown: a cluster commit is decided once every shard group the transaction touched holds a durable vote for it, not when the answer reaches the client, and a commit whose coordinating node died after that point is finished by the other nodes. Treat that case exactly like 08007: reconnect, verify whether it applied, and retry only if the write is idempotent or protected by the outbox recipe. A retry loop written only against the SQLSTATE table above, and not against this case, is the retry loop most likely to be wrong in production, because it is the one case with nothing to switch on.
SQLSTATE 40P01: deadlock_detected, across nodes
A deadlock in a single-node database is a cycle in one lock table. In a cluster the cycle can span nodes: transaction A on node 1 waits for a row locked by transaction B on node 2, which waits for a row locked by A. No single node's lock table can see that cycle, so without cluster-wide detection both transactions would wait until their lock timeouts expired, which reads to a client as an unexplained stall rather than a deadlock.
ScramDB registers every refused blocking lock acquisition as a wait edge in the replicated metadata group, so the whole waits-for graph is visible in one place. When a cycle appears, one transaction is chosen as the victim deterministically (every node picks the same one from the same graph, so the decision needs no extra round of agreement) and aborted with 40P01. The survivors proceed immediately rather than waiting out a timeout.
Wait edges are cleared when the lock is granted, and expire on their own if the waiting node disappears, so a dead node cannot leave a phantom edge that deadlocks live traffic.
Treat 40P01 exactly as you treat it in PostgreSQL: the victim left no effect, so re-execute it. If you see it often, the fix is lock-ordering discipline in the application, not a retry loop.
SQLSTATE 42704: undefined_object, from an unknown session parameter
This one is not a cluster failure, is not retryable, and is not covered by classify; it belongs on this page because it is a case an AI coding agent probing session settings programmatically will hit. SET, SHOW and RESET all name the same parameter table, so a name the session does not recognize is refused the same way by every one of the three: SET nosuchparam = 'x', SHOW nosuchparam and RESET nosuchparam each return 42704 with the message unrecognized configuration parameter "{parameter}" and no hint text. A dotted name (a custom parameter in an extension's own namespace, for example myapp.flag) is always accepted by SET and RESET, whether or not it has been set before. SHOW is narrower: it looks the dotted name up in the session's own settings, so SHOW myapp.flag before anything has ever set it is also 42704 - only for SET and RESET is it only the unqualified name that must already be a known setting.
This is deliberately not an empty string, and SET is deliberately not a silent success either. Returning "" for an unknown parameter, or letting SET accept a name it does not recognize, is the less compatible behavior, not the more forgiving one: a caller that asks for a setting and gets an empty value back, or sets one that is quietly accepted and does nothing, may read that as a real, valid answer and act on it, silently. Every client that already targets PostgreSQL already knows how to handle 42704; returning it from all three commands is what makes an unrecognized parameter unambiguous instead of quietly wrong.
SQLSTATE 54001: statement_too_complex, from a statement that nests too deep
This one is not a cluster failure and is not covered by classify. PostgreSQL rejects a pathologically deep expression, or a routine that recurses too deeply, before running any of it, with a clean error rather than a crash; ScramDB matches that behavior, HINT included.
A statement's own expression tree (a very long flat chain of one operator, such as 1+1+1+... or a AND b AND c AND ..., thousands of terms long) is checked before planning ever begins, against a depth PostgreSQL's max_stack_depth session setting controls: the 2048kB default keeps ScramDB's own enforced depth at 1024 levels of nesting. Lowering max_stack_depth tightens the check further; raising it past the default has no additional effect, since 1024 levels is ScramDB's own proven-safe ceiling regardless of the session's setting. Message: stack depth limit exceeded, with PostgreSQL's own HINT: Increase the configuration parameter "max_stack_depth" (currently 2048kB), after ensuring the platform's stack depth limit is adequate.
A statement nested past 4,096 levels in all is refused with the same error and HINT while it is still being read, whatever max_stack_depth says: an operator chain more than about 4,000 terms long, or a chain of set operations (SELECT ... UNION ALL SELECT ...) more than about 4,000 arms long. The session survives the refusal and answers its next statement.
A function or procedure that recurses too deeply, whether through a direct self-call, PERFORM, or mutual recursion between routines, is refused the same way, with a message naming the depth reached.
Deeply nested parentheses, a deeply nested CASE, or a deeply nested chain of function calls are a different case, and carry a different code: the parser itself refuses them, as PostgreSQL's own parser does, with a syntax error (SQLSTATE 42601), never 54001. Deep parentheses and a deep function-call chain get PostgreSQL's own memory exhausted at or near "(" wording; a deeply nested CASE gets an ordinary syntax error at or near ... instead, naming whatever token the parser gave up on. Hundreds of levels of nesting are fine; only a pathological depth is refused.
Neither case is retryable as written: an ordinary statement is never refused by this check, so seeing it means the statement, or the recursive routine, genuinely needs to be smaller.
SQLSTATE 22003: numeric_value_out_of_range
This one is not a cluster failure and is not covered by classify. It is PostgreSQL's own code for an arithmetic result that does not fit its type, and ScramDB raises it for the same reasons PostgreSQL does: an integer, bigint, or smallint result outside its type's range (integer out of range, and the equivalent for the other widths), a floating-point result outside IEEE range (value out of range: overflow or ...: underflow), and a NUMERIC result past its precision, whether that precision is a declared NUMERIC(p,s) or the 38 significant digits every unconstrained NUMERIC is bounded to (numeric field overflow: result exceeds 38 significant digits). None of these are retryable as written: the value or the query needs to change.
SQLSTATE 57014: query_canceled
This one is not a cluster failure and is not covered by classify. Three independent sources feed the same cancellation: statement_timeout elapsing, a call to pg_cancel_backend(), and PostgreSQL's own out-of-band wire protocol CancelRequest (a client opens a second, unauthenticated connection and presents the backend's process id and secret key). All three interrupt a running statement, including one that is purely CPU-bound rather than waiting on I/O, and return 57014 with one of PostgreSQL's own two messages: canceling statement due to statement timeout or canceling statement due to user request. The session itself is unaffected and keeps answering; only the cancelled statement is aborted, so re-issue it if it should still run.
SQLSTATE 42883: undefined_function, from LIKE over a non-text operand
This one is not a cluster failure and is not covered by classify. PostgreSQL's ~~ operator family (LIKE, NOT LIKE, ILIKE, NOT ILIKE) exists for text and bytea only; ScramDB refuses the same way at planning, before a statement like int_col LIKE '%5%' or enum_col LIKE 'a%' ever runs, with a message naming the operand's real type: operator does not exist: {type} ~~ unknown (and the analogous ~~*, !~~, !~~* for ILIKE and the negated forms). Cast the operand to text first if a text comparison is genuinely what is wanted.
This contract is tested, not just asserted
The mapping above is pinned down by tests that fail if any code or class in this table drifts: every way a commit can end is checked against its exact contracted SQLSTATE and retry class, a conflict between two commits is asserted to be 40001 and never 40P01, a transaction that never reached a commit is asserted never to be in doubt, and the availability and default codes are checked against this same table. Real clusters answer the same codes over the wire. If this page and the engine's behavior ever disagree, that is a bug in the engine, not a stale doc.