The read-update race in Django

A common race condition that can happen in Django apps when two requests read and update the same database value at the same time, and how row locking helps avoid it.

djangopythonrace-conditionsdatabaseatomic-transactionsconcurrencylocking

When we write backend code, we often think in a simple request-by-request way. One request comes in, we read something from the database, make a decision in code, save the new value, and return a response. For many normal CRUD views, this mental model works well enough. But it becomes risky when the decision depends on a database value that another request can change at the same time.

This is not a Django-specific problem. It can happen in any application where the code reads a value, checks it, and then updates it. Django just makes the example easy to understand because the ORM makes database code feel very natural. Sometimes too natural, actually. The code can look clean, local testing can pass, and still the logic can break when two requests run together in production.

A ticket booking example makes this easier to see. Suppose there is only one seat left, and two users try to book it at almost the same time. If both requests read the old seat count before either one saves the new value, both requests may think the seat is available. Each request is doing something that looks reasonable on its own, but together they can put the system in a wrong state.

The same pattern appears in many real apps: wallet balance, coupons, product stock, credits, subscriptions, and payment flows. Anywhere the code first checks a value and then updates it, concurrency should be part of the thinking, even if only for a few seconds.

The innocent-looking code

Here is the kind of code that looks completely normal when we first see it:

def book_ticket(event_id):
    event = Event.objects.get(id=event_id)

    if event.available_seats > 0:
        event.available_seats -= 1
        event.save()

There is nothing strange here. The code fetches the event, checks whether seats are available, reduces the number, and saves the object. It is small, readable, and it matches how we usually explain application logic in our head.

If this is tested manually, it will also behave as expected. Create an event with one available seat, call the function once, and the seat count goes down. Call it again, and the condition blocks the booking because there are no seats left. From this kind of testing, the code feels correct.

But manual testing usually runs requests in a very friendly order. One request finishes, then the next request starts. Production does not always behave like that. In production, two requests can run together and both can reach the same part of the code before either one has saved the updated value.

The bug hidden inside it

The code is doing a very common thing: read a value, make a decision in Python, and then write the new value back to the database. The risky part is the small gap between reading and writing. During that gap, another request can read the same old value and make the same decision.

Django is not doing anything surprising here. It is doing what the code asked it to do. It fetched a row, allowed the value to be checked in Python, and later saved the object. What the code did not say is: while this decision is being made, protect this database row from another request trying to update it at the same time.

That missing protection is the actual problem. The code is fine for one request, but it is not safe when two requests touch the same row together.

The two-request timeline

Let’s say there is only one seat left:

available_seats = 1

Now two users try to book the last seat at almost the same time. Request A reads available_seats = 1. Before Request A saves the updated value, Request B also reads available_seats = 1. Now both requests believe that a seat is available, because from their point of view, the check passed.

After that, Request A creates the booking or reduces the seat count. Request B also does the same thing because it already made its decision from the old value. The system can now accept more bookings than it should.

The annoying part is that the final seat counter may not always make the bug obvious. Depending on how the rest of the code is written, available_seats might still become 0, but two booking rows may exist for one seat. So checking only the counter is not always enough. The real issue is that two successful decisions came from the same old value.

This is why the bug is easy to miss. Testing like “click button, see result, click again” is not concurrency. That is just us being polite to our own app, and real users are not always going to be that polite.

What a race condition means here

A race condition means the final result depends on timing. If Request A finishes before Request B reads the row, everything may be fine. But if Request B reads before Request A saves, both requests can continue using the same old data.

This is why code can be logically correct for a single request and still be unsafe for real traffic. The problem is not the if condition itself. The problem is that the condition is based on a value that may already be outdated by the time the update is saved.

You will see this shape in many Django apps. Check if seats are available, then reduce seats. Check if a wallet has enough balance, then deduct money. Check if a coupon is unused, then mark it used. Check if stock exists, then reduce stock. These flows are simple, but they become sensitive when multiple requests can run at the same time.

Why local testing usually misses this

Local development hides this kind of problem very well. Most of the time, we run one development server, use one browser, and test one request at a time. Even if we click quickly, it may not create the exact timing needed to expose the bug.

There is also the database difference. Many Django projects use SQLite locally and PostgreSQL in production. SQLite is useful and simple for local development, but its locking behaviour is not the same as PostgreSQL. This does not mean SQLite is bad. It only means that if the correctness of a feature depends on locking and concurrent updates, the database behaviour matters.

For normal pages, this may not make any difference. For money, stock, seats, credits, coupons, and payments, it can make a big difference. These are the places where “it worked on localhost” is not very strong proof.

Why transaction.atomic() matters

Django provides transaction.atomic() for running a group of database operations inside a transaction. A transaction lets the database treat multiple operations as one unit. If everything succeeds, the transaction is committed. If something fails, the database can roll it back.

This matters because booking logic often has more than one database operation. Maybe the code creates a booking row and reduces the available seat count. We do not want one operation to succeed and the other one to fail. Both should belong to the same database unit.

So it is natural to try this:

from django.db import transaction

def book_ticket(event_id):
    with transaction.atomic():
        event = Event.objects.get(id=event_id)

        if event.available_seats > 0:
            event.available_seats -= 1
            event.save()

This is better than having no transaction around related writes, but for this specific race condition, it is not always the full answer. atomic() gives us a transaction boundary, but it does not automatically lock every row in the way we need. Another request can still read the same row and make its own decision unless we explicitly ask the database to lock it.

So atomic() is important, but for read-check-update flows, we usually need one more piece.

Why select_for_update() matters

Django’s select_for_update() tells the database to lock the selected rows until the transaction ends. In simple words, it says: this row is being read because it is going to be updated, so another transaction should not update this same row at the same time.

This is where row-level locking matters. When Request A selects the event row using select_for_update() inside a transaction, Request B has to wait if it tries to lock the same row. It cannot continue with the old value while Request A is still working with that row.

The flow becomes safer. Request A starts the transaction, locks the event row, reads available_seats = 1, reduces it to 0, saves, and commits. Only after that can Request B get the lock. When Request B finally reads the row, it sees available_seats = 0, so it does not create another booking.

That waiting is the important part. The second request is not blocked forever. It just waits until the first transaction finishes, then reads the latest value instead of making a decision from stale data. PostgreSQL explains these row locks in its explicit locking documentation, but this simple idea is enough to understand why the fix works.

The fixed code

Here is the safer version:

from django.db import transaction

def book_ticket(event_id):
    with transaction.atomic():
        event = Event.objects.select_for_update().get(id=event_id)

        if event.available_seats > 0:
            event.available_seats -= 1
            event.save()

The important detail is that the row is selected inside the transaction, and it is selected with select_for_update(). This means the database lock is active while the value is read, the decision is made, and the update is saved.

One common mistake is reading the object before entering the transaction:

from django.db import transaction

def book_ticket(event_id):
    event = Event.objects.get(id=event_id)

    with transaction.atomic():
        if event.available_seats > 0:
            event.available_seats -= 1
            event.save()

This looks close, but it misses the main point. The object was already read before the transaction started, so the decision can still be based on stale data. For this pattern, the read needs to happen inside the transaction, with the lock.

Another mistake is assuming .save() is magic. It is not. .save() saves the current object state. It does not know that the value read earlier was part of a sensitive concurrency decision. Django gives the tools, but we still need to use the right tool at the right part of the flow.

Keep the transaction small

There is one more practical thing that matters: do not put slow work inside the transaction. When a row is locked, other requests may be waiting for that lock. If the transaction stays open while the code calls a slow external API, sends emails, or waits for webhook-style work, other requests wait longer than needed.

This kind of code is not a good idea:

from django.db import transaction

def book_ticket(event_id):
    with transaction.atomic():
        event = Event.objects.select_for_update().get(id=event_id)

        if event.available_seats > 0:
            call_some_slow_payment_api()
            event.available_seats -= 1
            event.save()

The row stays locked while the network call is happening. If the API is slow, the lock is held for longer. If the API fails or times out, the flow becomes more messy than it needed to be.

A transaction should be kept small. Lock the row, read the current value, make the database decision, update the row, and commit. Slow external work should usually be outside that locked section, or the flow should be designed so that the database state is reserved first and the slow work happens after it. The exact design depends on the product, but the main idea is simple: do not turn a database lock into a waiting room.

Real places where this bug appears

Ticket booking is only the example. The same bug can appear in wallet systems where the code checks the balance and then deducts money. It can appear in coupons where the code checks whether the coupon is unused and then marks it used. It can appear in stock management where the code checks if stock is available and then reduces it.

Credits, subscriptions, and payment flows can also have the same shape. These are not places where we want two requests making decisions from the same old row. The bug may not happen every day, but when it happens, it creates data that is hard to explain later.

That is what makes this topic worth learning. It is not an advanced-looking bug. It comes from normal code that can look fine in a review, especially if nobody is thinking about two requests running together.

Things to be careful about

  • Whenever a database value is read and then used to decide whether something should be updated, pause for a second and ask: what happens if two requests run this code at the same time?

  • If the value is important for correctness, reading it before the transaction is usually a bad sign. The row should be read inside the transaction where the lock is taken.

  • One-request testing does not prove much for this kind of logic. Manual testing is useful, but it does not always show concurrency bugs. Even if a full concurrency test is not written every time, the timing problem should at least be considered before shipping code around money, seats, stock, or credits.

  • The transaction should stay small. Do the database work that needs the lock, then leave the transaction. Slow external API calls, emails, or payment requests should not sit inside the locked section unless there is a very specific reason.

Closing thoughts

The lesson is not that Django is unsafe. Django gives good tools for this. transaction.atomic() helps group database operations into one transaction, and select_for_update() helps lock the row when the decision depends on its current value.

The lesson is more simple: production is different from localhost. On localhost, code often runs in the clean order we imagine in our head. In production, two users can hit the same endpoint at the same time, and the gap between “read” and “save” can suddenly matter.

So when writing code that checks a value and then updates it, it is useful to pause for a second. Can two requests enter this code together? Can they both read the same value? Would it hurt if both of them continue? If the answer is yes, then it is probably time to think about transactions and row locks.

Not every model needs select_for_update(), and not every view needs this level of thinking. But for seats, money, stock, credits, coupons, subscriptions, and payment-related logic, localhost confidence is not enough. These are small details, but in real apps, small details like this decide whether the data stays correct.