Hello again! It's great to have you back.
In our last lesson, we established a strong theoretical understanding of concurrency problems, specifically race conditions. We contrasted the pessimistic and optimistic locking strategies and concluded that pessimistic locking is the go-to solution for high-contention scenarios where data integrity is paramount.
Today, we transition from theory to practice. This lesson is all about implementing pessimistic locking in a Django application. You will learn how to use the Django ORM's select_for_update() method to acquire a database lock, effectively solving the inventory management race condition we've been discussing. This is a critical skill for building robust backend systems that can handle simultaneous requests without corrupting data.
1. Introducing select_for_update()
As we discussed, pessimistic locking works by asking the database to lock a row (or rows) for exclusive use within a transaction. In Django, the ORM provides a clean, high-level method on the QuerySet to achieve this: select_for_update().
When you add .select_for_update() to a queryset, Django instructs the database to issue a SELECT ... FOR UPDATE SQL statement. This locks the selected rows, preventing any other transaction from modifying or acquiring a lock on them until your current transaction block is finished.
Django - select_for_update() function / Locking Database Rows
Let's begin with a practical demonstration. This video from the BugBytes channel clearly illustrates the race condition problem within an inventory system and introduces select_for_update() as the direct solution.
Watch the first 4 minutes of the video (00:00 - 04:04). The presenter explains why atomic transactions alone are not enough and sets up the classic concurrency problem we're about to solve. Note the important mention that this feature requires a database like PostgreSQL, not SQLite—a key reason we configured PostgreSQL earlier in this module.
The video highlights two critical requirements for select_for_update() to work correctly:
- It must be used within an atomic transaction. The lock's duration is tied to the transaction's life. If you call it outside a
transaction.atomic()block, the lock is acquired and immediately released, rendering it useless. Django will raise aTransactionManagementErrorto prevent this subtle but dangerous bug. - It requires a capable database backend. Row-level locking is a feature of sophisticated relational databases like PostgreSQL, Oracle, and modern versions of MySQL. Simpler databases like SQLite do not support it, and
select_for_update()will have no effect.
For a definitive reference, let's consult the official Django documentation.
The video gave a great overview. Now, let's look at the official Django documentation for the canonical explanation of select_for_update().
Read the first part of the select_for_update() section. Focus on its main description and the initial code example. This will solidify your understanding of the syntax and its essential connection to transaction.atomic().
2. Implementing Pessimistic Locking to Prevent Race Conditions
Now, let's write the code to solve the inventory problem. Imagine a function meant to handle a product purchase. A naive (and incorrect) implementation might look like this, even inside a transaction:
# WARNING: This code contains a race condition!
from django.db import transaction
@transaction.atomic
def naive_purchase_product(product_id, quantity):
product = Product.objects.get(pk=product_id) # Read
if product.stock >= quantity: # Modify (in memory)
product.stock -= quantity
product.save() # Write
return "Purchase successful!"
else:
return "Insufficient stock."
Even though this is atomic, two concurrent requests could both execute the Product.objects.get() line, both read the same stock value, and both pass the if check before either one has a chance to save the updated stock count.
The solution is to acquire a lock at the moment of reading. The following image shows the correct and robust pattern.

Let's break down why this code works:
with transaction.atomic():: Establishes the transaction boundary. The lock will be held until this block exits.product = Product.objects.select_for_update().get(pk=product_id): This is the crucial step. We are not just getting the product; we are getting it and immediately asking the database to place an exclusive lock on that row. If another transaction already holds a lock on this row, our code execution will pause right here and wait until that lock is released.if product.stock >= quantity:: This check is now guaranteed to be performed on the most up-to-date data, free from concurrent modifications.product.stock -= quantityandproduct.save(): The modification and write are safe.- Upon exiting the
withblock, the transaction commits, and the database automatically releases the lock, allowing other waiting transactions to proceed.
Let's see this pattern applied in a full Django view and observe its behavior.
Django - select_for_update() function / Locking Database Rows
The code pattern above is fundamental. Let's see it applied in a real Django view and observe its effect. This part of the BugBytes video also shows how to confirm the lock is working by checking the generated SQL in the Django Debug Toolbar—a great habit for understanding what the ORM does under the hood.
Watch from 10:57 to 17:39. The first part (until 13:27) demonstrates the blocking nature of the lock using time.sleep(). The second part (from 13:27) implements the solution in a view function and shows the FOR UPDATE SQL clause. This is a perfect example of connecting ORM methods to the underlying database behavior.
3. Advanced Control: nowait and skip_locked
The default behavior of select_for_update() is to wait for any conflicting lock to be released. This is often what you want, as it serializes access and ensures each transaction works in turn. However, sometimes you need more control.
What if you'd rather fail fast than have a user's request hang for an indeterminate amount of time? Or what if you're building a system where it's better to just grab the next available piece of work instead of waiting? Django provides two mutually exclusive options for these scenarios.
-
nowait=True: This makes the call non-blocking. If the row is already locked by another transaction,select_for_update()will not wait. Instead, it will immediately raise aDatabaseError. You can wrap this in atry...exceptblock to handle the conflict gracefully, perhaps by returning an error message to the user asking them to try again in a moment. -
skip_locked=True: This also makes the call non-blocking, but instead of raising an error, it simply excludes any locked rows from the result set. This is incredibly useful for implementing work queues. Imagine you have a table of "tasks to be processed" and multiple worker processes. Each worker can query the table withselect_for_update(skip_locked=True). They won't have to wait for each other; they will simply grab the next available, unlocked task row and start working on it.
This final segment of the video provides an excellent visual demonstration of these options.
Django - select_for_update() function / Locking Database Rows
Now let's explore how to change the default blocking behavior. The final part of the BugBytes video demonstrates the nowait and skip_locked arguments and the different exceptions they can lead to.
Watch from 17:39 to the end (23:19). Pay close attention to the distinct outcomes: the default waiting, the DatabaseError with nowait=True, and the DoesNotExist exception when using .get() with skip_locked=True on a locked row.
For a final reinforcement, this article provides a concise summary of these options and adds an important detail about how select_for_update interacts with related models.
How to Manage Concurrency in Django Models
The video showed the practical effects of the non-blocking options. This article provides a quick written reference.
First, review the 'Implementation' section to see the select_for_update pattern applied to a classmethod. This is a very robust way to structure your code. Then, read the 'What You Need to Know About select_for_update' section. It concisely covers nowait and points out that related objects fetched with select_related are also locked by default.
Test your understanding!
You are designing a system for a food delivery service. When a delivery driver wants to accept a new order, they query the Order table for orders with status='pending'. To prevent two drivers from accepting the same order, you must use locking.
Which locking strategy would be most appropriate for the driver's query?
Order.objects.select_for_update().filter(status='pending').first()(Default blocking)Order.objects.select_for_update(nowait=True).filter(status='pending').first()(Fail fast)Order.objects.select_for_update(skip_locked=True).filter(status='pending').first()(Skip locked)
Explain your choice.
Show answer
The most appropriate strategy is #3: skip_locked=True.
Here's why:
- The Goal: A driver wants to find any available order to deliver, not a specific one.
- Why #1 is not ideal: If two drivers happen to query at the exact same time and the database tries to give them both the same top order, the second driver will be forced to wait. This is inefficient. The second driver doesn't care about that specific order; they just want an order.
- Why #2 is not ideal: If the first available order is locked, this strategy would raise an error. The app would then have to handle the error and probably re-query. This is also inefficient and adds unnecessary complexity.
- Why #3 is perfect: With
skip_locked=True, if the first pending order is already locked by another driver's transaction, the query for the second driver will simply ignore it and fetch the next available unlocked pending order. This allows multiple drivers to concurrently and efficiently pick available orders from the queue without blocking each other or raising errors.
Conclusion
You have now successfully moved from the theory of pessimistic locking to its practical implementation in Django. This is a powerful tool for ensuring data integrity in high-contention environments, and understanding its nuances is a hallmark of a proficient backend developer.
Key Takeaways:
select_for_update()is Django's ORM method for implementing pessimistic locking, which generates aSELECT ... FOR UPDATESQL statement.- It is essential to use
select_for_update()within atransaction.atomic()block on a database that supports row-level locking (like PostgreSQL). - The default behavior is to block and wait if a requested row is already locked.
- The
nowait=Trueoption provides a fail-fast alternative, raising aDatabaseErrorimmediately. - The
skip_locked=Trueoption provides a work-queue friendly alternative, simply ignoring locked rows in the result set.
Preview of the Next Lesson:
We've spent a lot of time focusing on ensuring data correctness and handling contention. In the next lesson, we'll switch gears to address a different kind of database challenge: efficiently searching through large volumes of text. We will explore how to implement full-text search using PostgreSQL's powerful built-in features, allowing us to build much more sophisticated and performant search functionalities than simple LIKE queries can offer.