← Back to list

Django’s @atomic Decorator Didn’t Prevent My Race Condition (And the Docs Never Warned Me)

The insane reality where wrapping everything in @transaction.atomic still allows two users to book the same seat, and “all or nothing”…

Anas Issath · 2025-10-15 06:32 · 47 claps · 8.0 min read paywalled
#django #django-transaction #race-condition #concurrency #database-locking
Open on Medium ↗
Wiki topics: SOC · Sociology & Politics 🌐 · Web Development

Django’s @atomic Decorator Didn’t Prevent My Race Condition (And the Docs Never Warned Me)

Photo by Alejandro Drouet on Unsplash

Photo by Alejandro Drouet on Unsplash

The insane reality where wrapping everything in @transaction.atomic still allows two users to book the same seat, and “all or nothing” doesn’t mean “one at a time.”

If you’re not a Medium member, you can read this article for free via this link: Friend Link

I’ve seen Django betray developers before.

The QuerySet that executes 10,000 times. The get_or_create() that creates duplicates. The .update() that skips your signals.

But nothing — and I mean NOTHING — prepared me for this:

@transaction.atomic
def book_seat(user, seat_id):
    seat = Seat.objects.get(id=seat_id)

    if seat.is_available:
        seat.is_available = False
        seat.booked_by = user
        seat.save()
        return True
    return False

This is wrapped in @transaction.atomic. The docs say it's "all or nothing." I thought that meant it was safe.

Two users clicked “Book Seat” at the exact same millisecond.

Both got confirmation emails. Both got charged. Both showed up to the concert.

One seat. Two tickets. Zero errors. Total chaos.

The transaction was atomic. It rolled back perfectly when things failed. But it didn’t prevent two transactions from running simultaneously and both succeeding.

I thought @atomic meant "thread-safe."

Django’s docs never said it wasn’t.

The Code That Looked Perfect

Here’s what I wrote. Straight from Django’s transaction documentation:

from django.db import transaction

@transaction.atomic
def purchase_limited_item(user, item_id):
    """Buy a limited edition item. Only 100 available."""

    item = Product.objects.get(id=item_id)

    # Check if still available
    if item.stock > 0:
        # Create order
        order = Order.objects.create(
            user=user,
            product=item,
            price=item.price
        )

        # Decrement stock
        item.stock -= 1
        item.save()

        # Charge payment
        charge_user(user, item.price)

        return order

    raise OutOfStock("Sorry, sold out!")

What I THOUGHT @atomic guaranteed:

  1. ✅ All operations succeed or all fail (atomicity)
  2. ✅ No partial updates (consistency)
  3. ✅ No other transaction sees intermediate state (isolation)
  4. ✅ Changes are permanent after commit (durability)
  5. ✅ Thread-safe, prevents race conditions ❌❌❌ WRONG

What @atomic ACTUALLY guarantees:

  1. ✅ All operations succeed or all fail
  2. ✅ No partial updates
  3. ✅ No other transaction sees intermediate state
  4. ✅ Changes are permanent after commit
  5. Does NOT prevent concurrent access

Here’s what happened in production:

Time    Transaction A                   Transaction B
----    -------------                   -------------
0ms     BEGIN                           BEGIN
1ms     SELECT stock FROM product (100)
2ms                                     SELECT stock FROM product (100)
3ms     stock = 100, check passes ✓
4ms                                     stock = 100, check passes ✓
5ms     UPDATE stock = 99
6ms                                     UPDATE stock = 99  (NOT 98!)
7ms     COMMIT                          
8ms                                     COMMIT

Both transactions read stock = 100. Both decreased it to 99. Both succeeded.

We sold 2 items but only decremented stock by 1.

After 100 “successful” sales, stock showed 50. We oversold by 50 units. We didn’t have the inventory. We couldn’t fulfill orders.

Black Friday turned into refund Monday.

What the Hell is @atomic Actually Doing?

Let’s break down what @transaction.atomic DOES and DOESN'T do:

What @atomic DOES:

@transaction.atomic
def transfer_money(from_account, to_account, amount):
    from_account.balance -= amount
    from_account.save()

    # Simulate error
    if amount > 1000:
        raise ValueError("Amount too large")

    to_account.balance += amount
    to_account.save()

# If this fails, BOTH saves are rolled back
# from_account keeps its money
# Database stays consistent ✅

This is atomicity — all or nothing. Beautiful.

What @atomic DOESN’T DO:

@transaction.atomic
def decrement_counter(counter_id):
    counter = Counter.objects.get(id=counter_id)
    counter.value -= 1
    counter.save()

# Thread A: Reads value=10, sets to 9
# Thread B: Reads value=10, sets to 9 (SAME TIME)
# Result: value=9 (should be 8)
# Both transactions committed successfully ❌

This is a race condition — concurrent access isn’t prevented. Terrible.

@atomic prevents inconsistency WITHIN a transaction. It doesn't prevent inconsistency BETWEEN transactions.

The Proof: Let’s Break It

Still don’t believe concurrent transactions can interfere? Let’s prove it:

import threading
from django.db import transaction
from myapp.models import Counter

# Reset counter
Counter.objects.all().delete()
Counter.objects.create(id=1, value=100)

@transaction.atomic
def decrement():
    counter = Counter.objects.get(id=1)
    current = counter.value
    # Simulate some processing time
    import time
    time.sleep(0.01)
    counter.value = current - 1
    counter.save()
    print(f"Thread {threading.current_thread().name}: {current} -> {current-1}")

# Spawn 10 threads
threads = []
for i in range(10):
    t = threading.Thread(target=decrement, name=f"T{i}")
    threads.append(t)
    t.start()

for t in threads:
    t.join()

# Check final value
counter = Counter.objects.get(id=1)
print(f"Expected: 90, Actual: {counter.value}")
# Output: Expected: 90, Actual: 94 (or 95, or 93...)

Expected: 90 (100–10)

Actual: 94 (or 95, or 93, or 92…)

Each thread decremented. Each transaction was atomic. But the final result is WRONG.

Why? Because they all read the same starting value before any of them wrote.

When This Destroys Production

This isn’t academic. This is the bug that makes headlines.

Scenario 1: The Concert Ticket Oversell

@transaction.atomic
def book_ticket(user, event_id, seat_number):
    # Get the seat
    seat = Seat.objects.get(event_id=event_id, number=seat_number)

    # Check availability
    if not seat.is_booked:
        # Book it
        seat.is_booked = True
        seat.booked_by = user
        seat.save()

        # Create ticket
        ticket = Ticket.objects.create(
            user=user,
            seat=seat,
            price=seat.price
        )

        # Charge user
        charge_payment(user, seat.price)
        send_confirmation_email(user, ticket)

        return ticket

    raise SeatTaken("This seat is already booked")

Taylor Swift concert. 50,000 people hitting refresh.

User A clicks "Buy" → Checks seat 42A → Available → Books it
User B clicks "Buy" → Checks seat 42A → Available (A hasn't saved yet) → Books it
Both get charged. Both get tickets. Both show up. Security nightmare.

Result:

  • 10,000 seats available
  • 12,000 tickets sold
  • 2,000 angry fans with no seats
  • Venue at capacity
  • Refund 2,000 tickets manually
  • PR disaster

Scenario 2: The Bank Account That Went Negative

@transaction.atomic
def withdraw_money(account_id, amount):
    account = Account.objects.get(id=account_id)

    # Check sufficient balance
    if account.balance >= amount:
        # Withdraw
        account.balance -= amount
        account.save()

        # Record transaction
        Transaction.objects.create(
            account=account,
            type='withdrawal',
            amount=amount
        )

        return True

    raise InsufficientFunds("Not enough money")

User has $100. Tries to withdraw $80 twice simultaneously.

ATM 1: Check balance → $100 → Withdraw $80 → Balance = $20
ATM 2: Check balance → $100 → Withdraw $80 → Balance = $20
Final balance: $20 (should be $0 or reject one)
User withdrew $160 from $100 account

Result:

  • User got $160
  • Account shows $20
  • Bank lost $60
  • Multiply by thousands of users
  • Regulatory investigation
  • Someone gets fired

Scenario 3: The Promo Code Used 1,000 Times

@transaction.atomic
def apply_promo_code(user, code):
    promo = PromoCode.objects.get(code=code)

    # Check if still valid
    if promo.uses_remaining > 0:
        # Apply discount
        discount = Order.objects.create(
            user=user,
            promo=promo,
            discount_amount=promo.discount
        )

        # Decrement uses
        promo.uses_remaining -= 1
        promo.save()

        return discount

    raise PromoExpired("This code has been fully used")

Black Friday. Promo code “SAVE50” limited to 100 uses.

1000 users apply it simultaneously
All check → 100 uses remaining → All pass
All decrement → 99, 99, 99... (NOT 99, 98, 97...)
Final count: 99 (should be 0)
900 people got discount who shouldn't have
You lose $45,000 in revenue

Why select_for_update() Saves You

The fix isn’t more @atomic. It's select_for_update():

@transaction.atomic
def book_seat_correctly(user, seat_id):
    # LOCK the row while we work with it
    seat = Seat.objects.select_for_update().get(id=seat_id)

    if seat.is_available:
        seat.is_available = False
        seat.booked_by = user
        seat.save()
        return True
    return False

Now watch what happens:

Time    Transaction A                   Transaction B
----    -------------                   -------------
0ms     BEGIN                           BEGIN
1ms     SELECT FOR UPDATE (LOCKS row)
2ms                                     SELECT FOR UPDATE (WAITS...)
3ms     Check available → Yes
4ms     UPDATE available=False
5ms     COMMIT (UNLOCKS row)
6ms                                     (Lock acquired)
7ms                                     Check available → No
8ms                                     Return False
9ms                                     COMMIT

Transaction B waits until A is done. No race condition. No double-booking.

The Different Locking Strategies

Django gives you options for locking:

Option 1: select_for_update() (Pessimistic Locking)

@transaction.atomic
def decrement_stock(product_id):
    # Lock this row until transaction ends
    product = Product.objects.select_for_update().get(id=product_id)
    product.stock -= 1
    product.save()

Pros:

  • Prevents all race conditions
  • Simple to understand
  • Database handles synchronization

Cons:

  • Slower (other transactions wait)
  • Can cause deadlocks
  • Locks the entire row

Option 2: select_for_update(nowait=True)

@transaction.atomic
def try_to_book_seat(user, seat_id):
    try:
        # Try to lock, fail immediately if locked
        seat = Seat.objects.select_for_update(nowait=True).get(id=seat_id)
        seat.book(user)
        return True
    except DatabaseError:
        # Someone else is booking it, fail fast
        return False

Pros:

  • Doesn’t wait
  • User gets immediate feedback
  • Better UX than timeout

Cons:

  • Need to handle the exception
  • User might retry repeatedly

Option 3: select_for_update(skip_locked=True)

@transaction.atomic
def process_pending_jobs():
    # Get jobs that aren't locked by other workers
    jobs = Job.objects.filter(
        status='pending'
    ).select_for_update(skip_locked=True)[:10]

    for job in jobs:
        job.process()

Pros:

  • Multiple workers can process different jobs
  • No waiting
  • Great for job queues

Cons:

  • Doesn’t guarantee order
  • Can skip items temporarily

Option 4: F() Expressions (Optimistic)

from django.db.models import F

# Instead of:
product = Product.objects.get(id=product_id)
product.stock -= 1
product.save()

# Use F() to do it atomically at database level:
Product.objects.filter(id=product_id).update(
    stock=F('stock') - 1
)

Pros:

  • No locking needed
  • Very fast
  • Works at SQL level

Cons:

  • Can’t check conditions before update
  • Need separate query to verify result
  • No Python-level access to old value

Option 5: Database Constraints (Let DB Enforce)

class Product(models.Model):
    stock = models.IntegerField()

    class Meta:
        constraints = [
            models.CheckConstraint(
                check=models.Q(stock__gte=0),
                name='stock_non_negative'
            )
        ]

# Let the database reject invalid updates
@transaction.atomic
def purchase_item(product_id):
    product = Product.objects.get(id=product_id)
    product.stock -= 1
    try:
        product.save()  # Fails if stock would be negative
        return True
    except IntegrityError:
        return False  # Out of stock

Pros:

  • Database guarantees correctness
  • Can’t accidentally bypass
  • Works even with raw SQL

Cons:

  • Error handling needed
  • Less flexible than Python checks

The Isolation Level Trap

Here’s something Django doesn’t tell you: isolation level matters.

# PostgreSQL default: READ COMMITTED
# Can see changes from other committed transactions

@transaction.atomic
def bad_counter():
    counter = Counter.objects.get(id=1)  # Reads: 10
    # Another transaction commits, counter = 11
    counter.value -= 1  # Sets to 9 (not 10!)
    counter.save()

You can change isolation level:

from django.db import transaction

@transaction.atomic(isolation_level='SERIALIZABLE')
def safe_counter():
    counter = Counter.objects.get(id=1)
    counter.value -= 1
    counter.save()

But SERIALIZABLE is slow and can cause transaction conflicts.

Different databases support different levels:

  • SQLite: Only SERIALIZABLE
  • PostgreSQL: READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE
  • MySQL: Same as PostgreSQL

Django defaults to READ COMMITTED, which allows race conditions.

The Right Way to Think About Transactions

Stop thinking of @atomic as "thread-safe."

Start thinking of it as “rollback on error.”

What @atomic IS:

  • Error handling (rollback on exception)
  • Consistency within one transaction
  • “All or nothing” for YOUR operations

What @atomic ISN’T:

  • Concurrency control
  • Race condition prevention
  • Mutual exclusion

Better mental model:

# @atomic means:
try:
    # Do database operations
    # If any fail, undo all of them
except Exception:
    # Rollback everything
    raise

# @atomic does NOT mean:
with database_lock:  # ← This doesn't exist
    # Do database operations
    # Block everyone else

The Checklist

Before you ship code with @transaction.atomic, ask:

  1. Can this run concurrently?
  • Multiple users clicking same button?
  • Background workers processing same data?
  • Webhooks being retried?

2. Am I reading then writing based on that read?

  • Check stock → Decrement stock? ❌ Race condition
  • Check balance → Withdraw money? ❌ Race condition
  • Check availability → Book item? ❌ Race condition

3. Do I need to prevent concurrent access?

  • Yes → Use select_for_update()
  • No → @atomic alone is fine

4. Can I use F() expressions instead?

  • Incrementing/decrementing counters? Use F()
  • Mathematical operations? Use F()
  • Need to check conditions first? Can’t use F()

5. Do I have database constraints?

  • Unique constraints can prevent duplicates
  • Check constraints can prevent invalid states
  • Foreign keys can prevent orphans

The Takeaway

  • @transaction.atomic provides atomicity, NOT concurrency control
  • Race conditions happen when you read-then-write without locking
  • select_for_update() locks rows and prevents concurrent modifications
  • F() expressions do atomic updates at SQL level
  • Database constraints provide the ultimate safety net
  • Different isolation levels affect what transactions can see
  • Always test concurrent scenarios, not just single-user flows

Finally

This is peak Django confusion.

A decorator called “atomic” that doesn’t make things atomic in the way you expect.

A feature that prevents partial failures but not concurrent conflicts.

A guarantee of consistency that only applies within one transaction, not across them.

It’s not a bug. Databases work this way by design. But Django’s docs could be clearer.

Next time you write @transaction.atomic, ask yourself:

  • “Can two of these run at the same time?”
  • “Am I reading a value and then updating based on it?”
  • “Do I need select_for_update()?”

If you’re handling money, inventory, seats, or anything limited, the answer is YES.

Because @atomic prevents your transaction from being inconsistent.

But it won’t prevent two consistent transactions from conflicting with each other.

And remember: In Django, “atomic” doesn’t mean “exclusive access.”

Pro tip: Write a unit test that spawns multiple threads trying to do the same operation. If your test is flaky (sometimes passes, sometimes fails), you have a race condition. Add select_for_update() until it's stable.

Thanks for reading! ❤

If this helped you, consider clapping (50 👏 s), following, or sharing it. A writer without readers is just talking to themselves — so your time means everything.

Let’s keep building better, together.


메타데이터
post_id
58a98177cb9e
slug
djangos-atomic-decorator-didn-t-prevent-my-race-condition-and-the-docs-never-warned-me-58a98177cb9e
url
https://medium.com/@anas-issath/djangos-atomic-decorator-didn-t-prevent-my-race-condition-and-the-docs-never-warned-me-58a98177cb9e
canonical_url
https://medium.com/@anas-issath/djangos-atomic-decorator-didn-t-prevent-my-race-condition-and-the-docs-never-warned-me-58a98177cb9e
author_url
https://medium.com/@anas-issath
status
ok
fetched_at
2026-07-14 01:40:41