Transactions and concurrency Recommended
Know your isolation level
Know which isolation level each database uses by default, and raise it where a check and a write must see the same data.
How to do it
PostgreSQL defaults to Read Committed, MySQL's InnoDB to Repeatable Read, and SQLite runs writes one at a time. For "check, then write" logic, pick one of three fixes. Make the write itself conditional, lock the rows with SELECT … FOR UPDATE, or use SERIALIZABLE. Under SERIALIZABLE, retry the whole transaction on SQLSTATE 40001 or a deadlock (40P01 in PostgreSQL, error 1213 in MySQL). Cap the retries, such as 3, with a short random wait.
Example
async function withSerializableRetry(work, maxAttempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await db.tx({ mode: new TransactionMode({ tiLevel: isolationLevel.serializable }) }, work);
} catch (error) {
const isRetryable = error.code === '40001' || error.code === '40P01';
if (!isRetryable || attempt === maxAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, Math.random() * 50 * attempt));
}
}
}
connection.isolation_level = psycopg.IsolationLevel.SERIALIZABLE
def with_serializable_retry(work, max_attempts=3):
for attempt in range(1, max_attempts + 1):
try:
with connection.transaction():
return work(connection)
except (psycopg.errors.SerializationFailure, psycopg.errors.DeadlockDetected):
if attempt == max_attempts:
raise
time.sleep(random.random() * 0.05 * attempt)
How to check
A test runs the same "check, then write" from two connections at once. Exactly one succeeds, and the other retries or fails with a clear error.
For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers data playbook: https://agentwranglers.se/playbook/data/transactions-and-concurrency/isolation-levels.