go / retrytx
I use retryable transactions to handle transient database errors like lock timeouts and serialization failures.
The problem
Database operations can fail for transient reasons:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
// ... do work ...
err = tx.Commit()
// ERROR: could not serialize access (40001)
// ERROR: lock timeout (55P03)
These errors are recoverable — retrying often succeeds. But naive retry logic is error-prone: you must rollback the failed transaction before starting a new one.
The pattern
Wrap transaction logic in a retry loop:
var retryDelays = []time.Duration{
100 * time.Millisecond,
200 * time.Millisecond,
400 * time.Millisecond,
800 * time.Millisecond,
1600 * time.Millisecond,
}
func WithRetryableTx(ctx context.Context, db *sql.DB, fn func(*sql.Tx) error) error {
var lastErr error
for attempt := range len(retryDelays) + 1 {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
err = fn(tx)
if err == nil {
return tx.Commit()
}
lastErr = err
// Always rollback on error
if rbErr := tx.Rollback(); rbErr != nil {
return rbErr
}
// Check if error is retryable
if !isRetryable(err) {
return err
}
// Wait before retry, respecting context
if attempt < len(retryDelays) {
if err := sleepCtx(ctx, retryDelays[attempt]); err != nil {
return err
}
}
}
return lastErr
}
Retryable errors
For Postgres, retry on lock timeouts and serialization failures. See postgres for setup and error code reference.
import (
"errors"
"strings"
"github.com/jackc/pgx/v5/pgconn"
)
func isRetryable(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "40001": // serialization_failure
return true
case "40P01": // deadlock_detected
return true
case "55P03": // lock_not_available
return true
}
}
return false
}
Or use error class to retry on all class 40 (Transaction Rollback) errors:
func isClass(err error, class string) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return strings.HasPrefix(pgErr.Code, class)
}
return false
}
func isRetryable(err error) bool {
return isClass(err, "40") // class 40 = Transaction Rollback
}
For SQLite, retry on busy errors:
func isRetryable(err error) bool {
// modernc.org/sqlite returns error strings
return strings.Contains(err.Error(), "SQLITE_BUSY")
}
See backoff for why lookup tables are preferred, and jitter to avoid thundering herd.
Usage
err := WithRetryableTx(ctx, db, func(tx *sql.Tx) error {
_, err := tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, from)
if err != nil {
return err
}
_, err = tx.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, to)
return err
})
The function is called repeatedly until it succeeds, returns a non-retryable error, or the context is canceled.
Context-aware sleep
func sleepCtx(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return nil
}
}
See sleepctx for more details.
When to use
- Financial transactions that use
SERIALIZABLEisolation - High-concurrency workloads with lock contention
- Any transaction where transient failures are expected
When not to use
- Read-only queries (no transaction needed)
- Operations where retry would cause side effects (e.g., sending emails, calling external APIs)
- When the error indicates a permanent problem (constraint violation, missing table)