Driver FixRecommendedSound, Wi-Fi or graphics acting up? Check drivers firstFind missing or outdated drivers fast.Check DriversOctober DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsWindows FixRecommendedWindows errors stealing your time? Find the fix fastScan stability, cleanup and performance issues.Fix Now×
Skip to content
World desk6 min

The Second Race Condition: Why Updating a Record Can Be as Dangerous as Creating One

A valid final database row does not prove an update was race-safe. Learn how Django row locks protect read-decide-write flows, where they do not help, and how to test concurrent membership changes.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  1. Request A reads MEMBER.
  2. Request B reads MEMBER before A has finished.
  3. A saves ADMIN and sends a notification.
  4. B saves ADMIN and 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.

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

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 raises TransactionManagementError.
  • 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=True requests an immediate error instead of waiting on a conflicting lock. skip_locked=True omits 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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

A 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.

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.Support on Ko-Fi

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.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.”

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

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.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
PC Slower Than It Used to Be?Free scan - under a minute
Crashes, No Sound, or Screen Glitches?Free driver scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.