I. Overview
One of CockroachDB's most compelling features is its SERIALIZABLE isolation β the strongest isolation level defined by the SQL standard. In this part, we'll explore what this means in practice, how CockroachDB handles transaction conflicts, and how to write code that handles them gracefully.

II. A Quick Refresher on Isolation Levels
Before diving in, let's quickly recap the SQL isolation levels from weakest to strongest:
PostgreSQL defaults to Read Committed, though it also supports Serializable (SSI), which prevents write skew and other anomalies.
CockroachDB runs Serializable by default β the recommended and default isolation level. Since v23.2, CockroachDB also supports Read Committed for PostgreSQL migration compatibility (requires an enterprise license from v24.1), but Serializable remains the default.
This means every Serializable transaction behaves as if it ran one at a time, in some serial order, even when thousands are running concurrently.
π‘ Key Takeaway: With Serializable (the default), CockroachDB prevents dirty reads, phantom reads, and write skew. Your application still needs to handle SQLSTATE 40001 retry errors when conflicts occur.Note: This table uses the classic ANSI model. PostgreSQL's Repeatable Read uses snapshot isolation and prevents phantom reads in practice. Write skew is the anomaly that Read Committed allows but Serializable prevents β see Section VI.
III. How CockroachDB Handles Transactions
Transactions in CockroachDB work much like PostgreSQL:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;Under the hood, CockroachDB uses MVCC (Multi-Version Concurrency Control) β every write creates a new version of the data with an HLC timestamp. Reads see a consistent snapshot as of their start timestamp, without blocking writers.
Transaction lifecycle:
BEGINβ Transaction starts, acquires a start timestamp
- Read/Write operations β Reads see snapshot at start timestamp; writes create new versions
COMMITβ CockroachDB checks for conflicts; if none, commits atomically across all affected nodes
IV. Transaction Conflicts & Retry Logic
Here's where CockroachDB differs from traditional single-node databases. In a distributed cluster, conflict detection is global β two transactions can conflict when their reads and writes overlap on shared data, even if those keys live on different nodes. Transactions that touch completely disjoint data typically do not conflict. When a conflict is detected, CockroachDB returns a serialization error:
ERROR: restart transaction: TransactionRetryWithProtoRefreshError: ...
SQLSTATE: 40001This is not a bug β it's CockroachDB telling you: "A conflict was detected. Please retry this transaction."
Why retries instead of just blocking?
In a distributed system, blocking is expensive and can cause cascading delays. CockroachDB's approach is optimistic: assume no conflict, proceed, and retry if there is one. This leads to better throughput under most workloads.
V. Handling Retries in Application Code
The key rule: your application must be prepared to retry transactions that return serialization errors (SQLSTATE 40001).
Example in Go:
func transferFunds(db *sql.DB, fromID, toID int, amount float64) error {
maxRetries := 5
for attempt := 0; attempt < maxRetries; attempt++ {
err := runTransfer(db, fromID, toID, amount)
if err == nil {
return nil
}
// Check if it's a serialization error (SQLSTATE 40001)
if pqErr, ok := err.(*pq.Error); ok {
if pqErr.Code == "40001" {
fmt.Printf("Conflict detected, retrying (attempt %d)...\n", attempt+1)
continue
}
}
return err // Non-retryable error
}
return fmt.Errorf("transaction failed after %d retries", maxRetries)
}
func runTransfer(db *sql.DB, fromID, toID int, amount float64) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec(`UPDATE accounts SET balance = balance - $1 WHERE id = $2`, amount, fromID)
if err != nil {
return err
}
_, err = tx.Exec(`UPDATE accounts SET balance = balance + $1 WHERE id = $2`, amount, toID)
if err != nil {
return err
}
return tx.Commit()
}CockroachDB also supports client-side retry with a savepoint:
-- Loop this pattern until COMMIT succeeds or a non-retryable error occurs:
BEGIN;
SAVEPOINT cockroach_restart;
-- your SQL here
RELEASE SAVEPOINT cockroach_restart;
COMMIT;
-- On SQLSTATE 40001 before COMMIT, retry within the same transaction:
ROLLBACK TO SAVEPOINT cockroach_restart;
-- re-run your SQL, then RELEASE SAVEPOINT and COMMIT againVI. Practical Example: Write Skew Prevention
One anomaly that Read Committed databases allow but CockroachDB prevents is write skew.
Imagine a business rule: at least one doctor must be on call at all times.
-- Session 1 -- Session 2
BEGIN; BEGIN;
SELECT count(*) FROM doctors SELECT count(*) FROM doctors
WHERE on_call = true; WHERE on_call = true;
-- Returns: 2 -- Returns: 2
UPDATE doctors SET on_call = false UPDATE doctors SET on_call = false
WHERE id = 1; WHERE id = 2;
COMMIT; COMMIT;
-- Both succeed in Read Committed!
-- Now 0 doctors on call β BROKEN!In CockroachDB, one of these transactions would be aborted with a 40001 error, preserving the constraint. This is write skew prevention in action.
VII. Conclusion
Serializable isolation is both CockroachDB's greatest strength and its most important behavioral difference from standard PostgreSQL. The key things to remember:
- Always implement retry logic for
SQLSTATE 40001in your application
- Conflicts are normal under high concurrency β not errors to be alarmed about
- Serializable is the default; Read Committed is available from v23.2+ if you opt in for compatibility
- The trade-off is well worth it: you get strong correctness guarantees that would otherwise require complex application-level locking
In Part 5, we'll explore one of CockroachDB's most unique features: geo-distribution and multi-region deployments.
Thank you for reading! π




