Flash sale, 9:00 sharp. The product page says 5 left. By 9:01 the warehouse has 14 paid orders for that product. Every checkout ran inside DB::transaction(), so the team assumed it was safe. It was not: the code read stock into PHP, subtracted one, and wrote the number back. Two transactions read the same 5, both wrote 4, and both committed without a single error.
A transaction gives you atomicity: all of it commits or none of it does. It does not, on its own, make concurrent transactions behave as if they ran one after another. How much they can see of each other is controlled by the isolation level, and MySQL and PostgreSQL ship with different defaults and different behaviour under the same names. Here is what each level really guarantees and how to pick one in Laravel.
The Bug in Slow Motion
This is a lost update. Nothing in it is exotic:
-- Session A -- Session B
BEGIN; BEGIN;
SELECT stock FROM products WHERE id = 7; SELECT stock FROM products WHERE id = 7;
-- app sees 5, computes 5 - 1 = 4 -- app sees 5, computes 5 - 1 = 4
UPDATE products SET stock = 4 WHERE id = 7;
COMMIT;
UPDATE products SET stock = 4 WHERE id = 7;
COMMIT;
-- Two units sold, stock went from 5 to 4.
Under MySQL's default REPEATABLE READ and PostgreSQL's default READ COMMITTED, this interleaving commits happily. PostgreSQL REPEATABLE READ would reject session B's update with could not serialize access due to concurrent update. MySQL REPEATABLE READ would not, because InnoDB's UPDATE reads the latest committed row (a "current read"), not the transaction's snapshot. Same name, different outcome.
The Read Anomalies
The SQL standard defines isolation levels by which anomalies they forbid:
- Dirty read: you see data another transaction has not committed and may roll back.
- Non-repeatable read: you read the same row twice and get different values because someone committed in between.
- Phantom read: you run the same range query twice and new rows appear (or vanish) because someone inserted or deleted matching rows.
| Level | Dirty | Non-repeatable | Phantom (standard) |
|---|---|---|---|
| READ UNCOMMITTED | possible | possible | possible |
| READ COMMITTED | no | possible | possible |
| REPEATABLE READ | no | no | possible |
| SERIALIZABLE | no | no | no |
The standard says what must not happen. Engines are free to forbid more, and both of the big ones do.
MySQL InnoDB vs PostgreSQL
MySQL InnoDB, default REPEATABLE READ. Plain SELECTs are consistent reads from a snapshot taken at the first read of the transaction, so they see neither non-repeatable reads nor phantoms. Locking reads (SELECT ... FOR UPDATE, FOR SHARE) and UPDATE/DELETE work on the latest committed rows and take next-key locks (row plus gap) on the scanned index range, which blocks phantoms for writes but also blocks inserts into those gaps, a common source of deadlocks. READ UNCOMMITTED really does allow dirty reads. SERIALIZABLE turns plain SELECT into SELECT ... FOR SHARE when autocommit is off, so it is lock based and prone to lock waits.
PostgreSQL, default READ COMMITTED. Each statement sees a fresh snapshot of committed data. READ UNCOMMITTED is accepted but behaves exactly like READ COMMITTED; Postgres never shows dirty data. REPEATABLE READ is snapshot isolation: one snapshot for the whole transaction, no phantoms, and if you try to update a row that someone else changed after your snapshot, you get SQLSTATE 40001 and must retry. SERIALIZABLE adds Serializable Snapshot Isolation (SSI): it tracks read/write dependencies and aborts one transaction when the combination could not have happened serially. It uses no extra blocking locks for reads, but you must be ready to retry.
Snapshot isolation in both engines still allows write skew: two transactions read the same facts, each writes a different row, and together they break an invariant.
-- Rule: at least one doctor must stay on call for shift 42.
-- Both transactions run under REPEATABLE READ (snapshot isolation).
-- A: SELECT COUNT(*) FROM on_call WHERE shift_id = 42; -> 2, ok to leave
-- B: SELECT COUNT(*) FROM on_call WHERE shift_id = 42; -> 2, ok to leave
-- A: DELETE FROM on_call WHERE shift_id = 42 AND doctor_id = 1; COMMIT;
-- B: DELETE FROM on_call WHERE shift_id = 42 AND doctor_id = 2; COMMIT;
-- Result: nobody on call. Different rows were written, so no write conflict.
-- PostgreSQL SERIALIZABLE detects this and aborts one of them with SQLSTATE 40001.
Only PostgreSQL SERIALIZABLE (or explicit locking) prevents that.
Setting the level is also slightly different:
-- MySQL: applies to the NEXT transaction in this session only
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- ...
COMMIT;
-- PostgreSQL: must be the first statement inside the transaction
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ...
COMMIT;
-- Check the current level
SELECT @@transaction_isolation; -- MySQL
SHOW transaction_isolation; -- PostgreSQL
Choosing a Level
The isolation level is a blunt instrument. Most correctness bugs have a sharper fix, and the decision usually goes like this:
- Single-row read-modify-write (stock, balances, counters): do the math in SQL with an atomic
UPDATE ... SET stock = stock - 1 WHERE stock >= 1, or lock the row withlockForUpdate(). Works at any level, on both engines. - Reports and multi-query reads that must be internally consistent (totals matching line items):
REPEATABLE READgives one snapshot for the whole transaction. MySQL already does this by default; on PostgreSQL ask for it. - Invariants spanning rows you cannot easily lock (on-call rules, booking overlaps, "max 3 active sessions"): PostgreSQL
SERIALIZABLEwith retries, or a lock on a parent row (lock theshift, then touchon_call). - High-throughput OLTP on MySQL: many teams switch the session default to
READ COMMITTEDto reduce gap locks and deadlocks, then rely on explicit locks where needed.
Implementation in Laravel
Make the level explicit and type-safe with a backed enum, so nobody can pass arbitrary SQL into a SET statement:
namespace App\Database;
enum IsolationLevel: string
{
case ReadCommitted = 'READ COMMITTED';
case RepeatableRead = 'REPEATABLE READ';
case Serializable = 'SERIALIZABLE';
}
Laravel's DB::transaction($callback, $attempts) already retries when it detects a concurrency error (deadlocks, lock wait timeouts, SQLSTATE 40001). What it does not do is set the isolation level for you. The helper below wraps that, and handles the driver difference: PostgreSQL needs SET TRANSACTION as the first statement inside the transaction, MySQL needs it right before BEGIN, and since it only applies to the next transaction it has to be reissued on every retry.
namespace App\Database;
use Closure;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Throwable;
final class Isolated
{
/** SQLSTATEs that mean "safe to retry the whole transaction". */
private const RETRYABLE = ['40001', '40P01'];
/** @param Closure(ConnectionInterface): mixed $callback */
public static function run(
IsolationLevel $level,
Closure $callback,
int $attempts = 3,
?string $connection = null,
): mixed {
$db = DB::connection($connection);
for ($attempt = 1; ; $attempt++) {
try {
if ($db->getDriverName() === 'pgsql') {
return $db->transaction(function () use ($db, $level, $callback) {
// Must be the first statement of the transaction in PostgreSQL.
$db->statement('SET TRANSACTION ISOLATION LEVEL ' . $level->value);
return $callback($db);
});
}
// MySQL / MariaDB: SET TRANSACTION affects the next transaction only,
// so it has to be issued before BEGIN, on every attempt.
$db->statement('SET TRANSACTION ISOLATION LEVEL ' . $level->value);
return $db->transaction(fn () => $callback($db));
} catch (QueryException $e) {
if ($attempt >= $attempts || ! self::isRetryable($e)) {
throw $e;
}
usleep(random_int(5_000, 20_000) * $attempt);
}
}
}
public static function isRetryable(Throwable $e): bool
{
$state = $e instanceof QueryException ? ($e->errorInfo[0] ?? $e->getCode()) : $e->getCode();
return in_array((string) $state, self::RETRYABLE, true);
}
}
Then use the cheapest tool that is correct for each case:
namespace App\Actions;
use App\Database\Isolated;
use App\Database\IsolationLevel;
use App\Models\OnCall;
use App\Models\Product;
use DomainException;
use Illuminate\Support\Facades\DB;
final class InventoryActions
{
/** Atomic decrement: no isolation tricks needed, the row lock does the work. */
public function sell(int $productId, int $qty): void
{
$affected = Product::query()
->whereKey($productId)
->where('stock', '>=', $qty)
->decrement('stock', $qty);
if ($affected === 0) {
throw new DomainException('Out of stock');
}
}
/** Invariant across several rows: let SERIALIZABLE catch write skew, then retry. */
public function leaveShift(int $shiftId, int $doctorId): void
{
Isolated::run(IsolationLevel::Serializable, function () use ($shiftId, $doctorId) {
$count = OnCall::query()->where('shift_id', $shiftId)->count();
if ($count <= 1) {
throw new DomainException('Someone has to stay on call');
}
OnCall::query()
->where('shift_id', $shiftId)
->where('doctor_id', $doctorId)
->delete();
}, attempts: 5);
}
}
Testing Isolation Behaviour
Isolation is about concurrency, so a meaningful test needs two real connections to a real database. Clone the connection config under a second name, open a transaction on one, commit a change through the other, and assert what the first one sees. Do not use RefreshDatabase here: it wraps the test in a transaction on the default connection, which hides exactly the behaviour you want to observe.
// tests/Feature/IsolationLevelTest.php (runs against a real PostgreSQL database)
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
beforeEach(function () {
config(['database.connections.pgsql_b' => config('database.connections.pgsql')]);
Schema::connection('pgsql')->dropIfExists('iso_accounts');
Schema::connection('pgsql')->create('iso_accounts', function ($table) {
$table->id();
$table->integer('balance');
});
DB::connection('pgsql')->table('iso_accounts')->insert(['id' => 1, 'balance' => 100]);
});
afterEach(function () {
DB::purge('pgsql_b');
Schema::connection('pgsql')->dropIfExists('iso_accounts');
});
function balance(string $connection): int
{
return (int) DB::connection($connection)->table('iso_accounts')->where('id', 1)->value('balance');
}
it('sees a non-repeatable read under READ COMMITTED', function () {
$a = DB::connection('pgsql');
$a->beginTransaction();
expect(balance('pgsql'))->toBe(100);
DB::connection('pgsql_b')->table('iso_accounts')->where('id', 1)->update(['balance' => 50]);
expect(balance('pgsql'))->toBe(50);
$a->rollBack();
});
it('keeps a stable snapshot under REPEATABLE READ', function () {
$a = DB::connection('pgsql');
$a->beginTransaction();
$a->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');
expect(balance('pgsql'))->toBe(100);
DB::connection('pgsql_b')->table('iso_accounts')->where('id', 1)->update(['balance' => 50]);
expect(balance('pgsql'))->toBe(100);
expect(fn () => $a->table('iso_accounts')->where('id', 1)->update(['balance' => 90]))
->toThrow(QueryException::class, 'could not serialize access');
$a->rollBack();
});
Common Pitfalls
- Thinking
DB::transaction()prevents lost updates. It does not. Read-modify-write in PHP needslockForUpdate()or an atomic SQL update. - Assuming "REPEATABLE READ" means the same thing everywhere. MySQL's version lets
UPDATEsee newer data than yourSELECT; PostgreSQL's version aborts with40001. - Using SERIALIZABLE without retries. Serialization failures are normal, not exceptional. No retry loop means random 500s under load.
- Side effects inside a retried transaction. A retry replays the closure. Charging a card or dispatching a job inside it can happen twice.
- Setting the level at the wrong time. In PostgreSQL,
SET TRANSACTIONafter any query in the transaction fails. In MySQL, issuing it inside an active transaction fails; issuing it once and then retrying silently runs the retry at the session default. - Long transactions under REPEATABLE READ. Old snapshots keep old row versions alive (InnoDB undo log, Postgres dead tuples), which bloats storage and slows vacuum and purge.
- Gap-lock deadlocks on MySQL.
SELECT ... FOR UPDATEon a range inREPEATABLE READlocks gaps too. Lock by primary key where you can, keep a consistent lock order, and keep transactions short.
When to Use What, and When Not To
Use the defaults when your writes are single-row and expressed as atomic SQL, or protected by lockForUpdate(). That covers most CRUD apps.
Use REPEATABLE READ for multi-statement reads that must agree with each other, such as exports, invoices and reconciliation jobs, especially on PostgreSQL where it is not the default.
Use PostgreSQL SERIALIZABLE for business invariants across rows when locking is awkward, and only together with a retry loop and idempotent transaction bodies.
Do not raise the isolation level globally "to be safe". It increases aborts, lock waits and deadlocks across the whole app and still does not fix application-level read-modify-write on MySQL. Never use READ UNCOMMITTED for business logic.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Implement a tiny multi-version (MVCC) key-value store to see how isolation levels behave. MvccStore is already written: begin(level) accepts 'read_committed' or 'repeatable_read' (anything else throws InvalidArgumentException / ValueError) and returns a Transaction whose snapshot is the store's current committed version; readAt(key, maxVersion) (Python: read_at) returns the newest committed value with version <= maxVersion (null/None = no limit) or null/None; lastWriteVersion(key) (Python: last_write_version) returns the version of the last commit that wrote the key, 0 if never; apply(writes) commits a map of writes as one new version. Implement the Transaction class methods. read(key): return the transaction's own buffered write if present; otherwise, under read_committed return the latest committed value, under repeatable_read return the value as of the snapshot. write(key, value): buffer only, invisible to others until commit. commit(): under repeatable_read, if any key this transaction wrote has lastWriteVersion greater than the snapshot, discard the writes and throw SerializationFailure (first committer wins); under read_committed the last writer simply wins. Otherwise apply the buffered writes as one commit, but do not create a new version when there are no writes.
Starter code
final class SerializationFailure extends RuntimeException {}
final class MvccStore
{
/** @var array<string, list<array{int, mixed}>> key => list of [version, value] */
private array $history = [];
private int $version = 0;
public function begin(string $level): Transaction
{
if (!in_array($level, ['read_committed', 'repeatable_read'], true)) {
throw new InvalidArgumentException("Unknown isolation level: $level");
}
return new Transaction($this, $level, $this->version);
}
public function version(): int
{
return $this->version;
}
/** Newest committed value with version <= $maxVersion (null = no limit), or null. */
public function readAt(string $key, ?int $maxVersion = null): mixed
{
$found = null;
foreach ($this->history[$key] ?? [] as [$v, $value]) {
if ($maxVersion === null || $v <= $maxVersion) {
$found = $value;
}
}
return $found;
}
public function latest(string $key): mixed
{
return $this->readAt($key);
}
/** Version of the last commit that wrote $key, 0 if never written. */
public function lastWriteVersion(string $key): int
{
$rows = $this->history[$key] ?? [];
return $rows === [] ? 0 : $rows[array_key_last($rows)][0];
}
/** @param array<string, mixed> $writes */
public function apply(array $writes): void
{
$this->version++;
foreach ($writes as $key => $value) {
$this->history[$key][] = [$this->version, $value];
}
}
}
final class Transaction
{
/** @var array<string, mixed> */
private array $writes = [];
public function __construct(
private MvccStore $store,
private string $level,
private int $snapshot,
) {}
public function read(string $key): mixed
{
// TODO: return this transaction's own pending write if it has one,
// otherwise latest committed value (read_committed)
// or the value as of $this->snapshot (repeatable_read)
throw new LogicException('TODO');
}
public function write(string $key, mixed $value): void
{
// TODO: buffer the write; nothing is visible to others until commit()
throw new LogicException('TODO');
}
public function commit(): void
{
// TODO: under repeatable_read, throw SerializationFailure if any key you wrote
// was committed by someone else after your snapshot (first committer wins)
// TODO: otherwise apply the buffered writes to the store (skip if there are none)
throw new LogicException('TODO');
}
}
class SerializationFailure(Exception):
pass
class MvccStore:
def __init__(self) -> None:
self._history: dict[str, list[tuple[int, object]]] = {}
self._version = 0
def begin(self, level: str) -> "Transaction":
if level not in ("read_committed", "repeatable_read"):
raise ValueError(f"Unknown isolation level: {level}")
return Transaction(self, level, self._version)
def version(self) -> int:
return self._version
def read_at(self, key: str, max_version: int | None = None) -> object:
"""Newest committed value with version <= max_version (None = no limit), or None."""
found = None
for v, value in self._history.get(key, []):
if max_version is None or v <= max_version:
found = value
return found
def latest(self, key: str) -> object:
return self.read_at(key)
def last_write_version(self, key: str) -> int:
"""Version of the last commit that wrote key, 0 if never written."""
rows = self._history.get(key, [])
return rows[-1][0] if rows else 0
def apply(self, writes: dict[str, object]) -> None:
self._version += 1
for key, value in writes.items():
self._history.setdefault(key, []).append((self._version, value))
class Transaction:
def __init__(self, store: MvccStore, level: str, snapshot: int) -> None:
self.store = store
self.level = level
self.snapshot = snapshot
self.writes: dict[str, object] = {}
def read(self, key: str) -> object:
# TODO: return this transaction's own pending write if it has one,
# otherwise latest committed value (read_committed)
# or the value as of self.snapshot (repeatable_read)
raise NotImplementedError
def write(self, key: str, value: object) -> None:
# TODO: buffer the write; nothing is visible to others until commit()
raise NotImplementedError
def commit(self) -> None:
# TODO: under repeatable_read, raise SerializationFailure if any key you wrote
# was committed by someone else after your snapshot (first committer wins)
# TODO: otherwise apply the buffered writes to the store (skip if there are none)
raise NotImplementedError
Your solution
Hints
All hints revealedCheck the buffered writes first in read(); a transaction must always see its own changes
The only difference between the two levels on reads is the maxVersion you pass to readAt: the snapshot or no limit
The conflict check in commit() only looks at keys this transaction wrote, not keys it read
final class SerializationFailure extends RuntimeException {}
final class MvccStore
{
/** @var array<string, list<array{int, mixed}>> key => list of [version, value] */
private array $history = [];
private int $version = 0;
public function begin(string $level): Transaction
{
if (!in_array($level, ['read_committed', 'repeatable_read'], true)) {
throw new InvalidArgumentException("Unknown isolation level: $level");
}
return new Transaction($this, $level, $this->version);
}
public function version(): int
{
return $this->version;
}
/** Newest committed value with version <= $maxVersion (null = no limit), or null. */
public function readAt(string $key, ?int $maxVersion = null): mixed
{
$found = null;
foreach ($this->history[$key] ?? [] as [$v, $value]) {
if ($maxVersion === null || $v <= $maxVersion) {
$found = $value;
}
}
return $found;
}
public function latest(string $key): mixed
{
return $this->readAt($key);
}
/** Version of the last commit that wrote $key, 0 if never written. */
public function lastWriteVersion(string $key): int
{
$rows = $this->history[$key] ?? [];
return $rows === [] ? 0 : $rows[array_key_last($rows)][0];
}
/** @param array<string, mixed> $writes */
public function apply(array $writes): void
{
$this->version++;
foreach ($writes as $key => $value) {
$this->history[$key][] = [$this->version, $value];
}
}
}
final class Transaction
{
/** @var array<string, mixed> */
private array $writes = [];
public function __construct(
private MvccStore $store,
private string $level,
private int $snapshot,
) {}
public function read(string $key): mixed
{
if (array_key_exists($key, $this->writes)) {
return $this->writes[$key];
}
return $this->level === 'repeatable_read'
? $this->store->readAt($key, $this->snapshot)
: $this->store->readAt($key);
}
public function write(string $key, mixed $value): void
{
$this->writes[$key] = $value;
}
public function commit(): void
{
if ($this->level === 'repeatable_read') {
foreach (array_keys($this->writes) as $key) {
if ($this->store->lastWriteVersion((string) $key) > $this->snapshot) {
$this->writes = [];
throw new SerializationFailure("could not serialize access to $key");
}
}
}
if ($this->writes !== []) {
$this->store->apply($this->writes);
}
$this->writes = [];
}
}
class SerializationFailure(Exception):
pass
class MvccStore:
def __init__(self) -> None:
self._history: dict[str, list[tuple[int, object]]] = {}
self._version = 0
def begin(self, level: str) -> "Transaction":
if level not in ("read_committed", "repeatable_read"):
raise ValueError(f"Unknown isolation level: {level}")
return Transaction(self, level, self._version)
def version(self) -> int:
return self._version
def read_at(self, key: str, max_version: int | None = None) -> object:
"""Newest committed value with version <= max_version (None = no limit), or None."""
found = None
for v, value in self._history.get(key, []):
if max_version is None or v <= max_version:
found = value
return found
def latest(self, key: str) -> object:
return self.read_at(key)
def last_write_version(self, key: str) -> int:
"""Version of the last commit that wrote key, 0 if never written."""
rows = self._history.get(key, [])
return rows[-1][0] if rows else 0
def apply(self, writes: dict[str, object]) -> None:
self._version += 1
for key, value in writes.items():
self._history.setdefault(key, []).append((self._version, value))
class Transaction:
def __init__(self, store: MvccStore, level: str, snapshot: int) -> None:
self.store = store
self.level = level
self.snapshot = snapshot
self.writes: dict[str, object] = {}
def read(self, key: str) -> object:
if key in self.writes:
return self.writes[key]
if self.level == "repeatable_read":
return self.store.read_at(key, self.snapshot)
return self.store.read_at(key)
def write(self, key: str, value: object) -> None:
self.writes[key] = value
def commit(self) -> None:
if self.level == "repeatable_read":
for key in self.writes:
if self.store.last_write_version(key) > self.snapshot:
self.writes = {}
raise SerializationFailure(f"could not serialize access to {key}")
if self.writes:
self.store.apply(self.writes)
self.writes = {}

