Transaction Isolation Levels, Explained Without the Jargon
DEV Community

Transaction Isolation Levels, Explained Without the Jargon

Dirty reads, phantom reads, and the setting that quietly decides how safe your Django data really is.

Every Django developer uses database transactions. Far fewer know that transactions come with a dial: a setting called the isolation level that silently decides how much two simultaneous transactions are allowed to see of each other's half-finished work.

Turn the dial one way and your app is fast but can read data that's wrong or half-baked. Turn it the other way and your data is rock-solid but slower and prone to conflicts.

Most people never touch this dial, never learn it exists, and inherit its default without knowing what it protects them from, or doesn't.

This article is the plain-English tour of that dial. We'll cover:

  • The three main things that can go wrong when transactions overlap (the "anomalies")
  • The four isolation levels that prevent progressively more of them
  • What Django defaults to and why
  • When you'd actually change it

No SQL-standard jargon dumps, just the mental model.

The Core Trade-Off in One Sentence

Here's the whole topic compressed: Isolation level is the trade-off between concurrency (speed) and consistency (safety) when multiple transactions run at the same time.

Higher isolation prevents more data anomalies but reduces performance. That's it. Everything else is detail.

A database could run every transaction one-at-a-time, perfectly isolated, never interfering, but that would be catastrophically slow. So instead databases let transactions overlap for speed, and the isolation level is how you choose how much overlap-weirdness you're willing to tolerate in exchange for that speed.

Lower isolation = faster, but you might see anomalies. Higher isolation = safer, but slower and more conflicts.

To choose intelligently, you need to know what the "anomalies" actually are. There are three main ones, and they're easier to understand than their names suggest.

The Three Things That Can Go Wrong

Picture two transactions, A and B, running at the same time. Here's what can go wrong, from worst to subtlest.

  1. Dirty read: reading someone's uncommitted work

Transaction A deducts $100 from an account but hasn't committed yet. It might still fail and roll back. Transaction B reads the balance right now and sees the lowered amount.

Then A crashes and rolls back: the $100 was never actually deducted. But B already read it and acted on it. B read data that never truly existed.

That's a dirty read: seeing another transaction's uncommitted, possibly-about-to-vanish changes. It's the most dangerous anomaly, because you're acting on phantom data.

  1. Non-repeatable read: the same row changes under you

Transaction B reads Maria's balance: $0. A moment later, within the same transaction, B reads it again, and now it's $100, because transaction A committed a change in between.

B asked the exact same question twice and got two different answers. A row B already read got modified by someone else mid-transaction.

For logic that reads a value, does some work, then re-reads expecting it to be stable, this is a real hazard.

  1. Phantom read: new rows appear

Transaction B runs "count all active orders" and gets 10. It does some work, runs the same count again, and now it's 11, because transaction A inserted a new matching row in between.

No row B read changed. Instead, a new row appeared that matches B's query. The set of rows matching a condition changed mid-transaction. It's the "many rows" cousin of the non-repeatable read.

(There's a fourth, dirtiest-of-all case, dirty write, that all levels prevent, so we'll set it aside.)

Notice these get progressively subtler: reading uncommitted garbage → a value shifting → the row set shifting. The isolation levels are simply a ladder that blocks more of these as you climb.

The Four Isolation Levels: A Ladder

Each level blocks everything the levels below it block, plus one more anomaly. That's the whole structure.

Read Uncommitted (the bottom rung)

Blocks almost nothing. It even permits dirty reads: a transaction can see other transactions' uncommitted changes. It's the fastest and the least safe, and in practice it's rarely used for anything that matters. (PostgreSQL doesn't even truly implement it; it treats it as Read Committed.)

Read Committed (the practical baseline)

You can only read data that has been committed: successfully saved. This entirely prevents dirty reads. You'll never read someone's half-finished, about-to-be-rolled-back work.

But it still permits non-repeatable reads and phantom reads. Read the same row twice in one transaction and it can change, because another transaction committed in between.

This is the default in PostgreSQL, and Django's default, because it's the sweet spot for the vast majority of web apps: no reading of garbage data, minimal locking, high concurrency.

Repeatable Read

Guarantees that if you read a row, reading it again in the same transaction gives the same value. The row is effectively frozen for your transaction's view. This blocks dirty and non-repeatable reads.

In classic theory it still allows phantom reads, though PostgreSQL's implementation is stricter and prevents most of those too. The cost: more locking/versioning overhead and more chance of your transaction hitting a conflict and having to retry.

Serializable (the top rung)

The strongest: transactions behave as if they ran one after another, completely alone, in some serial order. It blocks all three anomalies, including phantoms.

It's the safest and the slowest, and it produces the most serialization failures. The database detects that two transactions can't both commit without violating the "as if serial" guarantee, and it aborts one, which your code must catch and retry.

You use this when correctness is non-negotiable and you're prepared to retry.

The ladder in one glance

Isolation level Dirty read Non-repeatable read Phantom read Read Uncommitted Possible Possible Possible Read Committed ✅ Blocked Possible Possible Repeatable Read ✅ Blocked ✅ Blocked Possible* Serializable ✅ Blocked ✅ Blocked ✅ Blocked *In classic theory. PostgreSQL's Repeatable Read prevents most phantoms too.

Higher = safer + slower + more retries.

What Django Actually Does

Two things every Django developer should know about how this plays out in practice.

Django defaults to READ COMMITTED, matching PostgreSQL

From the official docs: like PostgreSQL itself, Django defaults to the READ COMMITTED isolation level. So unless you've changed it, every transaction in your app reads only committed data (no dirty reads) but can see non-repeatable and phantom reads. For most apps, that's exactly right.

You change it in the DATABASES OPTIONS

Per the current docs, use the IsolationLevel enum rather than a raw string:

# settings.py
from django.db.backends.postgresql.psycopg_any import IsolationLevel
DATABASES = {
  "default": {
    "ENGINE": "django.db.backends.postgresql",
    # ... name, user, etc.
    "OPTIONS": {
      "isolation_level": IsolationLevel.READ_COMMITTED, # the default
      # or REPEATABLE_READ / SERIALIZABLE for stronger guarantees
    },
  }
}

That sets the default for all transactions on that connection. If you only need a stronger level for a specific operation (the common case), the clean approach is a second database alias pointed at the same database but with a higher isolation level, and route the sensitive transaction through it.

Or use a library like django-pgtransaction, which extends transaction.atomic() with a per-block isolation_level argument and built-in retry handling for the serialization failures that stronger levels cause.

⚠️ MySQL warning (it bites people): MySQL's own default is REPEATABLE READ, but Django works best with and defaults to READ COMMITTED. The docs explicitly warn that with MySQL's repeatable read, data loss is possible: you can see cases where get_or_create() raises an IntegrityError but the object won't appear in a subsequent get(). If you're on MySQL, let Django set READ COMMITTED rather than leaving MySQL's default in place.

When You'd Actually Change the Dial

The honest answer for most apps: leave it at Read Committed. It's the default for good reason. But there are real cases to climb the ladder:

Financial/consistency-critical operations where you read several values, make a decision, and write, and a value shifting underneath you (non-repeatable read) or new rows appearing (phantom) would produce a wrong result. Bumping that transaction to SERIALIZABLE (with retry logic) buys you correctness.

Reports/analytics that must be a consistent snapshot: reading many tables that must all reflect the same instant in time. REPEATABLE READ gives you a stable view for the duration.

And two crucial caveats before you reach for a higher level:

Higher isolation is not free, and it's not a substitute for locking or atomic updates

For a lot of the concurrency bugs people try to solve with isolation levels (like a counter losing increments, or a limit check letting one too many through), the better tools are:

  • An atomic F() update
  • A select_for_update() row lock
  • A database constraint

These are targeted, cheaper, and clearer than cranking the whole transaction to serializable. Isolation level is the blunt, global dial; those are the precise instruments.

Higher levels mean retries are mandatory

Serializable will abort transactions with serialization failures under contention. If you raise the level without wrapping the operation in retry logic, you've traded silent anomalies for loud crashes. That's often the right trade, but only if you actually handle the retries.

Why Understanding This Makes You Better at Django

Isolation levels sit underneath a whole family of "how is this data wrong?" bugs, and most developers never connect the symptom to this cause because the dial is invisible and defaulted.

The developer who understands it can look at a flaky, concurrency-related data bug and reason precisely: "I'm reading this value twice in one transaction and it's changing. That's a non-repeatable read, which Read Committed allows. I either need Repeatable Read here, or better, a row lock."

The developer who doesn't just sees "sometimes the number is wrong" and starts adding random time.sleeps and prayer.

And the mental model is genuinely simple once the jargon is stripped away: Databases let transactions overlap for speed. Overlapping causes three flavors of weirdness: reading uncommitted data, a row changing under you, new rows appearing.

The isolation level is a four-rung ladder where each rung costs performance to block one more flavor. Django hands you Read Committed by default (no dirty reads, high concurrency) and lets you climb higher for the specific transactions that need it, ideally alongside the sharper tools (F(), select_for_update, constraints) for the specific races those solve better.

Know the ladder, and "the data is mysteriously wrong under load" turns into a diagnosis with a name and a fix.

The 30-Second Takeaway

Isolation level is the dial trading concurrency (speed) against consistency (safety) when transactions overlap. Higher = safer but slower and more retries.

Three anomalies, subtlest-increasing: dirty read (reading uncommitted, maybe-rolled-back data) → non-repeatable read (a row you read changes mid-transaction) → phantom read (new rows matching your query appear mid-transaction).

Four levels, a ladder: Read Uncommitted (blocks nothing) → Read Committed (blocks dirty reads) → Repeatable Read (also blocks non-repeatable reads) → Serializable (also blocks phantoms, "as if run one at a time").

Django defaults to READ COMMITTED (like PostgreSQL): no dirty reads, but non-repeatable and phantom reads are possible. Great for most web apps.

Change it in DATABASES["OPTIONS"]["isolation_level"] using the IsolationLevel enum. For one-off stronger transactions, use a second DB alias or django-pgtransaction (which adds per-block level + retries).

MySQL warning: its default is Repeatable Read, but Django prefers Read Committed. The docs warn Repeatable Read can cause data loss (e.g. get_or_create raising IntegrityError then the object not appearing in get()).

Serializable requires retry logic (it aborts conflicting transactions). And for specific races (lost updates, limit checks), prefer targeted tools (F(), select_for_update(), constraints) over cranking the global isolation dial.

Your Turn

Next time a number is "sometimes wrong" under load, don't reach for a random sleep. Name the anomaly. Is a value changing mid-transaction, or are new rows appearing? The anomaly tells you whether you need a higher isolation level or a sharper tool like a row lock.

Have you ever changed the isolation level in production? Tell me in the comments 👇 If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.