A customer recently asked a great question:
- βIs there a list of errors that are safe to retry on the client?β
They had found the YugabyteDB documentation on Transaction retries in YSQL, which covers common retryable SQLSTATE values like
40001and40P01.
XX000 - WaitForAsyncWrite RPC ... timed out
XX000 - Tablet leader changed during async write
XX000 - Call waited in the queue past deadline (rpc error 4)
And that raised the real question:
- Should applications retry these
XX000errors?
The short answer is:
- No, not blindly.
The better answer is:
- Retry based on the error condition, not just the SQLSTATE.
Why This Gets Tricky
In PostgreSQL, XX000 means internal error.
YugabyteDB is PostgreSQL-compatible at the SQL layer, but under the covers it is a distributed database. Some distributed-system conditions can bubble up through YSQL as XX000, especially when the error does not map cleanly to a standard PostgreSQL SQLSTATE.
That does not mean every XX000 is retryable.
Some XX000 errors may represent a transient distributed-system condition, such as a leader change, tablet split, tablet shutdown, RPC timeout, queue timeout, or schema-version race.
Other XX000 errors may represent a real internal failure, unsupported operation, bug, or condition that should be investigated rather than retried forever.
So the deterministic guidance is this:
- Do not build retry logic based on
XX000alone.Β Example:
IF SQLSTATE = 'XX000' THEN retry;
That is too broad and can hide real internal errors.
Instead, build retry logic around two safer signals:
- β Retry known retryable SQLSTATEs, such as
40001and40P01. - β For
XX000, retry only when the error message matches a known-safe transient condition and the application can safely replay the transaction.
In other words:
- Retry the condition, not just the code.
Treat 40001 and 40P01 as normal application retry cases. Treat XX000 as a message-inspection case, not a blanket retry case.
The Official Retry-Friendly Cases
| SQLSTATE / Error | Meaning | Client Action |
|---|---|---|
40001 |
Serialization failure, usually caused by conflicting transactions. | Retry the operation with backoff. If this happened inside an explicit transaction block, rollback and retry the full transaction from the beginning. |
40P01 |
Deadlock detected. | Retry the operation with backoff. If this happened inside an explicit transaction block, rollback and retry the full transaction from the beginning. |
25P03 |
Idle-in-transaction session timeout. | Reconnect and retry only if the operation is safe to replay. |
For an explicit transaction block, the retry pattern usually looks like this:
BEGIN;
-- run transaction work
COMMIT;
If a retryable error happens before the transaction is successfully committed:
ROLLBACK;
-- wait with exponential backoff
-- retry the full transaction from the beginning
Important nuance
Not every statement should be wrapped in an explicit transaction block just to make retry logic look uniform. Many application statements, especially simple reads, run as standalone auto-commit statements. In YugabyteDB, those standalone statements are already implicit transactions. Wrapping them in an explicit transaction can add distributed transaction overhead and may hurt performance.
So the retry unit should match the application unit of work.
| Application Pattern | Retry Guidance |
|---|---|
| Explicit multi-statement transaction | Rollback and retry the full transaction from the beginning. |
| Standalone statement | Retry the standalone statement only when the error is known to be retryable and the statement is safe to replay. |
Important Read Committed Note
In YugabyteDBβs READ COMMITTED isolation level, many serialization conflicts are retried internally by the server.
That means applications using READ COMMITTED usually see fewer 40001 errors than applications using REPEATABLE READ or SERIALIZABLE.
But the application should still be prepared to handle retryable transaction failures when they are surfaced to the client.
Retry the full transaction, not just the failed statement. The YugabyteDB Transaction retries in YSQL documentation recommends rolling back after a retryable failure, waiting with backoff, and then retrying the transaction from the beginning.
Errors You Should Not Retry as Transaction Retries
Not every transaction-related error is retryable.
Some errors mean the application sent statements in the wrong order, attempted an invalid operation, referenced an invalid object, or left the transaction in an aborted state.
Those should be fixed in the application code.
| SQLSTATE / Error | Meaning | Client Action |
25006 |
Write attempted inside a read-only transaction | Do not retry. Fix the transaction logic. |
25P02 |
Current transaction is aborted after a prior error | Do not continue issuing SQL. Rollback or use savepoints. |
2D000 |
Invalid transaction termination | Do not retry. Fix the transaction structure. |
3B001 |
Invalid savepoint specification | Do not retry. Fix the savepoint logic. |
42XXX |
Syntax error, invalid reference, undefined table, undefined column, permission issue, or related SQL problem | Do not retry. Fix the SQL, object reference, or permission issue. |
Why Retrying Individual Statements Can Be Dangerous
Retrying one failed statement in the middle of an explicit transaction can leave the application in a confusing state. For example, a prior statement may have succeeded, a later statement may have failed, and the transaction may now be marked aborted.
In that state, the right answer is usually to rollback and retry the transaction from the beginning.
Standalone statements are different.
BEGIN / COMMIT block. This is common for simple SELECT statements and many single-statement reads or writes. These statements should not automatically be wrapped in an explicit transaction just for retry handling. In YugabyteDB, a standalone statement is already handled as an implicit transaction. For standalone statements, retry safety depends on two things:
- β Is the error a known retryable condition?
- β Is the statement safe to replay?
SELECT is usually safe to retry when the error is clearly transient. A state-changing statement needs more care. | Statement Pattern | Example | Retry Safety |
|---|---|---|
| Read-only statement | SELECT * FROM account_status WHERE account_id = 42; |
Usually safe to retry when the error is clearly transient. |
| Set a value to a known final state | UPDATE account_status SET last_seen_at = now() WHERE account_id = 42; |
Generally safer to replay because the statement sets a value rather than incrementing or decrementing one. |
| Change a value based on current table state | UPDATE account_balance SET amount = amount + 100 WHERE account_id = 42; |
Not naturally safe to replay. If the first attempt took effect, retrying could apply the change twice. |
For example, this statement is naturally safer to replay because it sets the column to a specific value:
UPDATE account_status
SET last_seen_at = now()
WHERE account_id = 42;
But this statement is not naturally idempotent:
UPDATE account_balance
SET amount = amount + 100
WHERE account_id = 42;
If the client does not know whether the first attempt took effect, blindly retrying that statement could apply the increment twice.
COMMIT, the application may not know whether the transaction committed or failed. Retrying blindly can create duplicate work unless the operation is safe to replay. An operation is idempotent when running it more than once produces the same final result as running it once. For retry logic, this means the application can safely replay the work without changing the intended outcome.
Important
Do not try to solve every retry case with more retry logic. Some operations are hard to make safely retryable, especially state-dependent updates such as counters, balance adjustments, inventory decrements, or βadd this amountβ style changes. For those cases, the safer answer may be to fail fast, log enough context, and let the application reconcile the outcome instead of blindly replaying the statement.
The YugabyteDB Transaction retries in YSQL documentation cautions against retrying unfamiliar internal errors and commit or auto-commit failures where the application cannot know whether the work committed.
What About XX000?
XX000 is where you need to be careful.
Some XX000 errors are transient and may be safe to retry. Some are not.
That is why XX000 should not be treated as a retryable SQLSTATE by itself. Instead, use an allowlist of known-safe transient messages.
| Error Pattern | Likely Cause | Retry Guidance |
XX000 - WaitForAsyncWrite RPC ... timed out |
A later operation waited for a prior async write to finish, but the wait exceeded the deadline | Usually transient. Retry the full transaction with backoff if the operation is safe to replay. |
XX000 - Tablet leader changed during async write |
Tablet leadership changed while the distributed write path was in progress | Usually transient. Retry the full transaction after rollback/reconnect. |
XX000 - Call waited in the queue past deadline |
The RPC sat in a server queue too long, often due to overload, saturation, or temporary unavailability | Retry with exponential backoff and jitter, but also investigate load, queues, latency, and cluster saturation. |
XX000 with schema or catalog version mismatch |
Concurrent DDL changed metadata while DML was running | Retryable when it is clearly a schema/catalog version mismatch. |
XX000 with tablet peer shutting down |
Tablet split, leader movement, node restart, or tablet lifecycle transition | Often transient. Retry if the application can safely replay the operation. |
XX000 with unfamiliar internal error text |
Unknown internal condition, unsupported operation, bug, or unexpected failure | Do not retry blindly. Log it, alert on it, and investigate. |
A Good Application Retry Policy
A good retry policy should be conservative.
Start with known retryable SQLSTATE values:
40001 -- serialization failure / retryable transaction conflict
40P01 -- deadlock detected
Then optionally add a small allowlist of known transient YugabyteDB message patterns.
The important part is to keep this list explicit. Do not treat every XX000 as retryable.
| SQLSTATE | Error Pattern | Retry Guidance |
40001 |
Serialization failure | Rollback and retry the full transaction with backoff. |
40P01 |
Deadlock detected | Rollback and retry the full transaction with backoff. |
40001 |
schema version mismatch | Retry the full transaction with backoff. |
40001 |
Catalog Version Mismatch | Retry the full transaction with backoff. |
40001 |
Restart read required | Retry the full transaction with backoff. |
40001 |
Operation failed. Try again | Retry the full transaction with backoff. |
40001 |
Conflicts with higher priority transaction | Retry the full transaction with backoff. |
40001 |
Transaction expired | Retry the full transaction with backoff. |
XX000 |
Tablet peer … is shutting down | Retry only if it matches this known transient condition and the operation is safe to replay. |
XX000 |
WaitForAsyncWrite RPC … timed out | Retry only if the transaction can be safely replayed. Also investigate latency or overload if frequent. |
XX000 |
Tablet leader changed during async write | Retry only if it matches this known transient condition and the operation is safe to replay. |
XX000 |
Call waited in the queue past deadline | Retry with backoff and jitter, but investigate server queueing, saturation, and RPC deadlines. |
Some errors that previously surfaced as XX000 may be returned as 40001 in newer YugabyteDB versions when the condition is known to be retryable. That is why retry logic should prefer official retryable SQLSTATEs first, then use a narrow message allowlist for selected transient XX000 cases.
Retry logic should not immediately hammer the cluster again.
A simple retry policy should include:
- β A maximum retry count
- β Exponential backoff
- β Jitter
- β Logging of each retry
- β Metrics for retry count and retry exhaustion
- β A clear failure path after the retry budget is exhausted
Example policy:
max_attempts = 10
initial_sleep = 2ms
backoff_multiplier = 2
max_sleep = 1s
jitter = random 0-25%
If retries are happening frequently, that is not just an application behavior. It is a signal.
It may indicate contention, hot keys, overloaded tablet servers, leader movement, tablet splitting, connection issues, or cluster saturation.
Retries are a resilience tool. They are not a substitute for fixing the root cause.
Be Extra Careful With Non-Idempotent Writes
Some operations are naturally safe to retry.
For example:
UPDATE account_balance
SET last_seen_at = now()
WHERE account_id = 42;
Others may not be safe:
INSERT INTO payments(amount, customer_id)
VALUES (100.00, 42);
If the client does not know whether the first attempt succeeded, a blind retry could create a duplicate payment
For non-idempotent operations, add a business-level idempotency key:
CREATE TABLE payments (
payment_id uuid PRIMARY KEY,
customer_id bigint NOT NULL,
amount numeric NOT NULL,
created_at timestamptz DEFAULT now()
);
Then the application can safely retry using the same payment_id.
If the first attempt succeeded, the retry can detect that the payment already exists.
If the first attempt failed, the retry can complete the work.
Recommended Decision Flow
Use this decision flow:
Did the error return SQLSTATE 40001?
Yes -> Rollback and retry the full transaction with backoff.
Did the error return SQLSTATE 40P01?
Yes -> Rollback and retry the full transaction with backoff.
Did the error return SQLSTATE 25P03?
Yes -> Reconnect and retry the transaction if safe to replay.
Did the error return SQLSTATE 25P02?
Yes -> The transaction is already aborted.
Rollback. Do not keep issuing statements.
Did the error return SQLSTATE 25006, 2D000, 3B001, or 42XXX?
Yes -> Do not retry as a transaction retry.
Fix the application logic, SQL, object reference, or permissions.
Did the error return XX000?
Yes -> Inspect the message.
Retry only if it matches a known transient allowlist
and the operation is safe to replay.
Is the error unfamiliar?
Yes -> Do not retry blindly.
Log the full error, SQLSTATE, message, query fingerprint,
transaction context, and application request ID.
Final Takeaway
Client-side retries are an important part of building resilient applications on YugabyteDB, but the retry rule needs to be precise.
40001 and 40P01. For XX000, do not retry based on the code alone. Retry only when the message matches a known transient condition and the application operation is safe to replay. For explicit transaction blocks, rollback and retry the full transaction. For standalone statements, do not wrap everything in an explicit transaction just for retry handling. Retry the standalone statement only when the error is known to be retryable and the statement is safe to replay.
The goal is not to retry more. The goal is to retry safely, with backoff, limits, observability, and application logic that avoids turning a transient failure into duplicate work.
That gives your application deterministic retry behavior without hiding real errors or turning a transient failure into duplicate work.
Special Thanks
Thank you to Patnaik Balivada, Senior Software Engineer on the YugabyteDB LRT, CoreDB team, for reviewing this tip and providing feedback that helped make the retry guidance more accurate and precise.
Have Fun!
