Updating an existing row is not automatically safe from races. In a Django membership example, two requests changing the same role can both read the old value and send duplicate notifications—even though the database ends with just one correct row. A concurrent deletion can also leave application code running as if a save succeeded, although no row was updated. The fix is to protect the decision as well as the write, and to test the invariant the application actually needs.
How an update race can happen
Consider a workspace membership with role MEMBER and an operation called change_member_role. Each request looks up the membership, checks its current role, saves ADMIN, and sends a role-change notification if the role differed.
As an Amazon Associate I earn from qualifying purchases.
Majid Khazaei describes an audit of a Django WorkspaceService in which two concurrent requests could interleave this way:
Recommended Free Tools
- Request A reads
MEMBER. - Request B reads
MEMBERbefore A has finished. - A saves
ADMINand sends a notification. - B saves
ADMINand sends another notification.
The stored role is valid and there is still one membership row. Yet the operation has produced two notifications for what users may regard as one effective change. This is an author-reported incident and example; it was not independently reproduced here.
#1 Best Overall
Why atomic() alone may not protect the decision
Django’s transaction.atomic() groups database changes so they commit together on success or roll back if an exception exits the block. That is important, but it does not by itself make every read, decision, and write behave as one serialized operation. If two transactions can both read the old role before either writes, both can make the same decision and trigger a side effect.
Think of the code as two responsibilities: deciding whether the change is needed, and applying it. A transaction protects the database work as a unit; a row lock or a suitable atomic database operation is needed when the invariant depends on preventing competing requests from making decisions based on the same stale state.
Django also provides transaction callbacks such as on_commit() for work that should run only after a successful commit. The callback itself is not part of the transaction, however. It does not deduplicate notifications or make delivery exactly once; those requirements need their own design.
Rank #2
Protecting the membership row with select_for_update()
For a workflow whose decision depends on the current membership row, Django’s select_for_update() requests a row lock on supported database backends. A competing operation that requests a conflicting lock normally waits until the current transaction releases it. The second request can then read the state left by the first and decide whether any work remains.
from django.db import transaction
with transaction.atomic():
membership = Membership.objects.select_for_update().get(
workspace=workspace,
user=user,
)
if membership.role != new_role:
membership.role = new_role
membership.save(update_fields=["role"])
# Arrange the notification only if a change was made.
This illustrates the locking shape, not a drop-in service implementation: authorization checks, missing-row behavior, notification delivery, and error handling depend on the application. The key property is that the read and the decision based on it happen while the row lock is held in the same transaction.
Khazaei reports that applying this pattern to the membership role-change path serialized the competing operations: the later request could observe the committed role and take a no-op path rather than emit the same change notification again. That outcome belongs to the described application and code path, not a guarantee that a row lock makes arbitrary side effects exactly once.
Rank #3
Backend and transaction requirements
- Evaluate the locking query inside a transaction on a backend that supports it. Django documents that using
select_for_update()in autocommit mode on supporting databases raisesTransactionManagementError. - Django 5.2 documents support for PostgreSQL, Oracle, and MySQL, with option differences for MySQL and MariaDB. SQLite has no effect for this API: it adds no locking clause.
- Locks remain held until the surrounding transaction ends. Keep that transaction short; do not perform slow network calls while holding a database lock.
nowait=Truerequests an immediate error instead of waiting on a conflicting lock.skip_locked=Trueomits rows already locked by another transaction. These options change conflict behavior and are not interchangeable substitutes for choosing the right invariant.
See Django’s select_for_update() reference for backend-specific behavior and option support.
PC Slower Than It Used to Be?
A free scan shows the junk files, broken settings and background clutter dragging Windows down - then fixes them in one click.Free scan · Windows 10 & 11Crashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteA deletion race needs explicit handling too
A related hazard appears when one transaction reads a membership while another deletes it. Khazaei describes a Django path where a later save against the deleted row updates zero rows without raising an exception, while subsequent notification code still runs. This is a code-specific behavior described by the author, not a universal statement about every ORM or save path.
A lock can make the ordering explicit: the role-changing transaction locks and uses the row, or waits for a deletion to finish and then discovers the row is missing. The application must still decide what “missing” means—such as treating the operation as a no-op or reporting that the membership no longer exists—and ensure it does not send a change notification for an update that did not occur.
Rank #4
In the author’s removal example, the operation locks one membership row. That particular one-row path does not establish that deadlocks are impossible in a broader service. Any workflow that locks multiple rows, or interacts with other transactions that do, must consider lock ordering.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Lock only what the invariant requires—and order locks consistently
Pessimistic row locking is one way to serialize operations, but the scope should match the rule being protected. Locking a membership row can protect decisions about that membership; it does not automatically protect unrelated rows or guarantee exactly-once delivery to an external service.
When a transaction needs multiple locks, acquire them in a consistent order across code paths. PostgreSQL documents that transactions taking conflicting row locks in opposing orders can deadlock. The database detects a deadlock and aborts one participant, but which transaction is aborted is difficult to predict. Keep the transaction short and handle the possibility of a failed transaction where appropriate. See PostgreSQL’s explicit-locking documentation.
Test concurrency contracts, not a particular winner
Concurrent requests are not guaranteed to run in a particular order. Tests should therefore assert the application’s invariants and the allowed outcomes, rather than assume which thread wins. Khazaei reports testing these membership scenarios with real threads and transactional tests; those tests and results were not independently run here.
| Scenario | Useful assertions | What not to assume |
|---|---|---|
| Two requests set the same role | One membership remains; its role is the requested role; only one role-change notification is produced; no integrity error occurs. | That both requests must independently produce a change event. |
| Two requests set different roles | One membership remains; its final role is one of the two requested roles; no integrity error occurs. | Which requested role wins. The winner can depend on transaction order. |
| A role change races with removal | The final membership count is zero if removal is the operation under test, regardless of which transaction runs first. | That a particular request must always run first. The author used an owner actor to make the test order-independent because an admin actor could be blocked by the resulting role, depending on transaction order. |
Django recommends TransactionTestCase for testing select_for_update(): the usual TestCase wraps each test in a transaction, which can mask the behavior the test is meant to check. Consult Django’s transaction-testing guidance. A meaningful concurrency test should exercise separate transactions against the same supported database behavior used in production; a SQLite run cannot validate this row-locking API.
When to audit sibling methods
Finding a race in a create path should prompt a review of related update and removal paths. Look especially for code that reads a row, makes a decision from its current state, then mutates or deletes it—or emits a notification—without protecting that decision. Khazaei’s takeaway is apt: “When you find one, don’t patch it and move on. Audit the sibling methods.”
Quick wins for a faster PC:
Scan for outdated or missing drivers - takes under a minuteDriver Scan →Repair Windows errors before they cause bigger problemsFix Now →Quick Recap
Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.




