CyberHuginn

Home

/

Notes

/

django-transactions-atomic-select-for-update

Django Transactions: What atomic() Actually Protects

Learn how Django transactions work, what transaction.atomic() actually protects, and how to handle race conditions, row locking, database constraints, nested transactions, and post-commit side effects with select_for_update() and on_commit().

Django

Backend

Python

Sep 13, 2026 · 14 min read

transaction.atomic() is one of the most important tools in Django when multiple database operations must behave as a single unit.

But there is a common misunderstanding:

atomic() does not make your entire operation magically safe.

It gives you database transaction atomicity. It does not automatically prevent race conditions, lock rows, roll back external API calls, or guarantee that background tasks wait for a successful commit.

Understanding these boundaries is essential when building production Django applications.

In this article, we'll look at what transaction.atomic() actually does, how it interacts with row locking and database constraints, how nested transactions work, and how to safely handle external side effects.

What Is a Database Transaction?

A database transaction is a group of operations that the database treats as one unit of work.

Imagine creating an order and its payment record:

order = Order.objects.create(
    customer=customer,
    total=100,
)

Payment.objects.create(
    order=order,
    amount=100,
)

What happens if creating the payment fails?

You could end up with:

Order       ✓ created
Payment     ✗ failed

Now the database contains an order without the payment record that was expected.

A transaction lets us define a boundary around both operations:

from django.db import transaction

with transaction.atomic():
    order = Order.objects.create(
        customer=customer,
        total=100,
    )

    Payment.objects.create(
        order=order,
        amount=100,
    )

If the block completes successfully, the transaction can be committed.

If an exception causes the atomic block to roll back, the database changes made inside that transaction are rolled back.

The key idea is:

atomic() protects the atomicity of database operations. It does not make an entire Python function, HTTP request, or external system atomic.

Django Uses Autocommit by Default

Django normally runs in autocommit mode.

In practical terms, database operations are generally committed individually unless you explicitly establish a transaction boundary.

For example:

user = User.objects.create(
    username="alice",
)

profile = Profile.objects.create(
    user=user,
)

These operations are not automatically one indivisible unit simply because they appear next to each other in Python code.

If both operations must succeed or fail together, use an atomic block:

from django.db import transaction

with transaction.atomic():
    user = User.objects.create(
        username="alice",
    )

    profile = Profile.objects.create(
        user=user,
    )

Now both operations participate in the same database transaction.

What Does atomic() Actually Guarantee?

At its core, transaction.atomic() gives you a transaction boundary.

Conceptually:

                    transaction.atomic()

                          │
             ┌────────────┴────────────┐
             │                         │
          Success                    Failure
             │                         │
             ↓                         ↓
           COMMIT                   ROLLBACK

For example:

with transaction.atomic():
    account.balance -= 100
    account.save(update_fields=["balance"])

    Transaction.objects.create(
        account=account,
        amount=-100,
    )

If creating the Transaction record raises an exception and the atomic block rolls back, the balance update can be rolled back as well.

This is atomicity.

But atomicity is not the same as concurrency control.

That distinction is extremely important.

atomic() Does Not Automatically Prevent Race Conditions

Consider a simple inventory system.

Suppose:

product.stock = 1

Two customers try to purchase the last item at almost the same time.

A naive implementation might be:

with transaction.atomic():
    product = Product.objects.get(pk=product_id)

    if product.stock > 0:
        product.stock -= 1
        product.save(update_fields=["stock"])

It is inside a transaction.

Is it automatically safe?

No.

Two transactions can potentially read the same value before either one updates it:

Request A                 Request B
---------                 ---------

Read stock = 1            Read stock = 1

stock > 0                 stock > 0

stock = 0                 stock = 0

save()                    save()

Both requests saw the same state and both decided that inventory was available.

The transaction gave us atomicity, but it did not automatically serialize access to the row.

This is where concurrency control becomes important.

Row Locking With select_for_update()

Django provides select_for_update() for cases where a transaction needs to lock selected database rows.

For example:

from django.db import transaction

with transaction.atomic():
    product = (
        Product.objects
        .select_for_update()
        .get(pk=product_id)
    )

    if product.stock <= 0:
        raise OutOfStock()

    product.stock -= 1
    product.save(update_fields=["stock"])

On databases that support SELECT ... FOR UPDATE, the selected row is locked for the duration of the transaction.

Now the responsibilities are clearer:

atomic()
    ↓
Defines the transaction boundary

select_for_update()
    ↓
Locks selected rows during that transaction

These are related, but they solve different problems.

atomic() asks:

Should these database operations succeed or fail together?

select_for_update() asks:

Should this row be protected from concurrent access while I make this decision?

atomic() vs select_for_update()

A useful mental model is:

ToolMain responsibility
transaction.atomic()Transaction boundary
select_for_update()Row-level locking
Database constraintsEnforce invariants
transaction.on_commit()Delay side effects until commit

For example, a wallet withdrawal might require both a transaction and row locking:

from django.db import transaction

with transaction.atomic():
    account = (
        Account.objects
        .select_for_update()
        .get(pk=account_id)
    )

    if account.balance < amount:
        raise InsufficientFunds()

    account.balance -= amount
    account.save(update_fields=["balance"])

The transaction groups the changes.

The row lock prevents concurrent transactions from simultaneously making decisions based on the same balance.

A Real Example: Concurrent Withdrawals

Imagine an account has:

Balance = 100

Two requests arrive:

Request A: withdraw 80
Request B: withdraw 50

Without appropriate concurrency control, both requests could read:

balance = 100

Both conclude that there is enough money.

That is a classic race condition.

With row locking:

with transaction.atomic():
    account = (
        Account.objects
        .select_for_update()
        .get(pk=account_id)
    )

    if account.balance < amount:
        raise InsufficientFunds()

    account.balance -= amount
    account.save(update_fields=["balance"])

The second transaction must wait for the first transaction to release the lock, subject to the database and locking configuration.

The critical section is therefore serialized.

Nested atomic() Blocks

Django supports nested atomic() blocks:

with transaction.atomic():
    create_order()

    with transaction.atomic():
        create_payment()

However, nested atomic() blocks do not normally create completely independent database transactions.

The outermost atomic block establishes the main transaction.

Inner atomic blocks generally use savepoints.

Conceptually:

Outer transaction
│
├── create order
│
├── Savepoint
│   │
│   └── create payment
│
└── Commit or rollback

This distinction matters.

A successful inner atomic() block does not mean its changes have already been permanently committed.

The outer transaction can still roll everything back.

Savepoints Are Not Independent Transactions

Consider:

with transaction.atomic():
    order = create_order()

    with transaction.atomic():
        create_invoice()

    do_something_else()

The inner block can create a savepoint.

If an error occurs inside that inner block and the exception is handled around the appropriate boundary, Django can roll back to the savepoint while allowing the outer transaction to continue.

Conceptually:

One database transaction
│
├── Savepoint
├── Work
├── Roll back to savepoint if necessary
└── Final commit

It is not:

Transaction A
Transaction B

The distinction becomes especially important when designing error handling.

Be Careful With IntegrityError

One of the most important transaction rules in Django is:

Don't catch a database error inside an atomic block and blindly continue using the same transaction.

For example:

with transaction.atomic():
    try:
        create_payment()
    except IntegrityError:
        pass

    create_order()

After a database error such as IntegrityError, Django may mark the transaction as needing rollback.

Continuing to perform queries in that broken transaction can result in:

TransactionManagementError

A safer pattern is to put the atomic boundary around the operation whose failure you want to handle:

try:
    with transaction.atomic():
        create_payment()
except IntegrityError:
    handle_payment_error()

Now the exception leaves the atomic block, allowing Django to roll back that transaction appropriately.

The placement of try and atomic() is therefore important.

atomic() Cannot Roll Back External Systems

This is one of the most important limitations of database transactions.

Consider:

with transaction.atomic():
    order = create_order()
    charge_credit_card()

Suppose the payment provider successfully charges the customer.

Then a later database operation fails.

Django can roll back the database transaction.

But it cannot automatically undo the payment provider's successful HTTP request.

You now have two different systems:

Your Database
      │
      │ HTTP
      ↓
Payment Provider

The database transaction controls the database.

It does not automatically control:

  • Payment gateways
  • HTTP APIs
  • Email providers
  • SMS providers
  • File storage
  • External databases
  • Other services

A database rollback cannot magically roll back an external side effect.

Use transaction.on_commit() for Side Effects

Django provides transaction.on_commit() for callbacks that should run only after a successful transaction commit.

Instead of:

with transaction.atomic():
    order = create_order()
    send_notification(order)

you can use:

from django.db import transaction

with transaction.atomic():
    order = create_order()

    transaction.on_commit(
        lambda: send_notification(order)
    )

The callback runs after the transaction successfully commits.

If the transaction rolls back, the callback is not executed.

This is particularly useful for background tasks.

For example:

with transaction.atomic():
    order = create_order()

    transaction.on_commit(
        lambda: process_order.delay(order.pk)
    )

Without on_commit(), a Celery worker could start processing the task before the transaction creating the order has committed.

That can lead to subtle problems where the worker cannot see the expected database state.

Don't Put Slow Network Calls Inside Transactions

Avoid this pattern:

with transaction.atomic():
    order = create_order()

    response = requests.post(
        payment_url,
        json={"amount": order.total},
    )

    create_payment_record(response)

The problem is that the database transaction remains open while waiting for an external system.

The network request might take:

50 ms
500 ms
5 seconds
30 seconds

During that time, the database transaction remains open.

Depending on the workload, this can increase lock contention, hold database resources longer, and reduce throughput.

A better architecture is usually:

Short database transaction
        ↓
Commit
        ↓
External operation
        ↓
Retry / reconcile / update state

For operations such as payments, webhooks, and other unreliable external services, you will often also need:

  • Explicit state transitions
  • Idempotency
  • Retries
  • Timeouts
  • Reconciliation
  • Unique constraints

A transaction is only one part of the design.

Database Constraints Still Matter

A transaction is not a replacement for database constraints.

Suppose an email address must be unique.

The database should enforce that rule:

class User(models.Model):
    email = models.EmailField(unique=True)

This is unsafe as the only protection:

if not User.objects.filter(email=email).exists():
    User.objects.create(email=email)

Two concurrent requests can both execute the exists() query before either creates the user.

Both might observe:

User does not exist

and then both attempt to create the record.

A database-level unique constraint provides the final guarantee.

This is why production systems commonly combine:

Transactions
    +
Database constraints
    +
Concurrency control

Each one protects a different aspect of consistency.

Atomic Updates Can Also Help

Not every concurrency problem requires select_for_update().

Sometimes the safest solution is to let the database perform the update atomically.

For example:

from django.db.models import F

updated = (
    Product.objects
    .filter(
        pk=product_id,
        stock__gt=0,
    )
    .update(stock=F("stock") - 1)
)

if updated == 0:
    raise OutOfStock()

Here, the database performs the conditional update as a single statement.

This can be preferable to:

SELECT
    ↓
Python decision
    ↓
UPDATE

because the condition and update are handled together by the database.

The right approach depends on the invariant and the database operation you need.

ATOMIC_REQUESTS

Django also supports:

ATOMIC_REQUESTS = True

This wraps each view in a transaction for the configured database.

It can be convenient, but it does not mean that literally everything related to the HTTP request becomes transactional.

For example, middleware runs outside the view transaction, and response handling has its own boundaries.

There is also a performance consideration.

Wrapping every request in a transaction can create unnecessary transaction overhead and may keep transactions open longer than necessary.

For that reason:

Prefer meaningful, short transaction boundaries over making every request transactional by default.

A Production Order Workflow

Consider an order system where inventory and payment records must remain consistent.

A reasonable database transaction might look like:

from django.db import transaction

with transaction.atomic():
    product = (
        Product.objects
        .select_for_update()
        .get(pk=product_id)
    )

    if product.stock < quantity:
        raise OutOfStock()

    product.stock -= quantity
    product.save(update_fields=["stock"])

    order = Order.objects.create(
        customer=customer,
        product=product,
        quantity=quantity,
    )

    Payment.objects.create(
        order=order,
        amount=order.total,
        status="pending",
    )

    transaction.on_commit(
        lambda: notify_order_created.delay(order.pk)
    )

Notice that each tool has a specific responsibility:

atomic()
    ↓
Keeps database changes together

select_for_update()
    ↓
Protects inventory from concurrent updates

Database constraints
    ↓
Protects business invariants

on_commit()
    ↓
Delays the background side effect until commit

The important part is not simply using more transaction-related tools.

It is using the right tool for the right problem.

Common Mistakes

1. Assuming atomic() Prevents Race Conditions

It doesn't automatically lock every row you read.

If concurrent requests can modify the same shared state, consider:

  • select_for_update()
  • F() expressions
  • Database constraints
  • Optimistic concurrency
  • Appropriate isolation levels

depending on the problem.

2. Making Transactions Too Large

Avoid putting slow operations inside transactions.

Long-running transactions can increase contention and resource usage.

3. Calling External APIs Inside Transactions

A database rollback cannot undo an HTTP request that already succeeded.

4. Starting Celery Tasks Before Commit

A worker can run before the transaction has committed.

Use:

transaction.on_commit(
    lambda: task.delay(object.pk)
)

when the task depends on committed database state.

5. Catching Database Errors Inside atomic()

Don't hide an IntegrityError and continue blindly inside the same broken transaction.

Structure exception handling around the atomic boundary.

6. Using Transactions Without Constraints

Transactions don't replace:

  • UNIQUE
  • CHECK
  • Foreign keys
  • Other database-level invariants

When Should You Use atomic()?

Use transaction.atomic() when multiple database changes represent one logical operation.

Typical examples include:

  • Creating an order and related records
  • Transferring money between accounts
  • Updating inventory and creating a reservation
  • Creating payment and ledger records
  • Updating several related models consistently
  • Performing a multi-step state transition

You don't necessarily need an explicit transaction around every database query.

For a single independent database operation, adding an atomic block may provide little value.

The goal is to define a meaningful transaction boundary.

A Practical Decision Checklist

Before adding transaction.atomic(), ask:

1. Which database changes must succeed together?

2. What happens if a later step fails?

3. Can two requests modify the same data concurrently?

4. Do I need row locking?

5. Can the operation be expressed as an atomic database update?

6. Should the database enforce a constraint?

7. Am I performing network or slow work inside the transaction?

8. Should a Celery task wait until commit?

9. Where should database exceptions be caught?

10. How long will this transaction remain open?

These questions are more useful than simply adding @transaction.atomic to every service function.

The Mental Model to Remember

Instead of thinking:

atomic() = make everything safe

think:

transaction.atomic()
        ↓
Define a database transaction boundary
        ↓
Commit together
        OR
Rollback together

Then add other mechanisms when the problem requires them:

┌──────────────────────────────┐
│      Database Consistency    │
└──────────────┬───────────────┘
               │
      ┌────────┼────────┐
      ↓        ↓        ↓
   atomic()  Locks   Constraints
      │        │        │
 Transaction  Race    Invariants
 boundary    control
               │
               ↓
         on_commit()
               │
               ↓
        External side effects

Each mechanism has a different responsibility.

Final Takeaway

transaction.atomic() is powerful, but it is not a magic safety switch.

Its primary responsibility is database transaction atomicity:

Database changes succeed
        OR
Database changes roll back

It does not automatically:

  • Prevent race conditions
  • Lock every row you access
  • Roll back external API calls
  • Make network operations transactional
  • Replace database constraints
  • Make long-running operations safe
  • Guarantee that Celery runs after commit

Production-grade transaction handling usually combines several mechanisms:

transaction.atomic()
        ↓
Transaction boundary

select_for_update() / F()
        ↓
Concurrency control

Database constraints
        ↓
Invariant enforcement

transaction.on_commit()
        ↓
Post-commit side effects

The goal is not to put atomic() everywhere.

The goal is to define the right transaction boundary, protect shared state from concurrency problems, enforce invariants at the database level, and keep external side effects outside the transaction whenever possible.

Once you understand what each tool actually protects, Django transaction handling becomes much easier to reason about—and much safer to use in production.

Persian Version

The Persian version of this article is available on Virgool:

Read the Persian version on Virgool

Related Notes

The Hidden Cost of Django REST Framework Serializers

Learn how Django REST Framework serializers can cause N+1 queries, slow API responses, and unnecessary database work—and how to optimize them with select_related, prefetch_related, annotations, and better serializer design.

Building a Django Package — Part 4: Publishing django-healthkit to PyPI

In this final part, we prepare django-healthkit for release, build and validate the package, test it on TestPyPI, publish it to PyPI, and create a Git tag and GitHub release.

Building a Django Package — Part 3: Health Check Manager and Endpoint

In Part 3, we connect the database and cache health checks, build the health check manager, expose a Django health endpoint, measure check latency, and return a structured health status.

Building a Django Package — Part 2: Database and Cache Health Checks

In this part, we implement the first health checks for django-healthkit, covering database connectivity and Django cache functionality with simple, independent, and testable checks.

Building a Django Package — Part 1: Setting Up django-healthkit

In this part, we build the initial structure of django-healthkit, configure the package with pyproject.toml, and prepare it for development.

Building a Secure Webhook Receiver for Server-to-Server Communication

How I built a secure FastAPI webhook receiver using RSA signatures to enable authenticated server-to-server communication, proxy requests, and connect applications across different network environments.

Why I Switched to Conventional Git Commit Messages

Why I switched from inconsistent Git commit messages to a Conventional Commits style, with practical examples for cleaner and more maintainable Git history.

Building django-healthkit

How a simple health endpoint for Bidar turned into django-healthkit, a lightweight Django health-check package.

Designing a Gold Jewelry E-Commerce Database with Django

Learn how to design a scalable Django database for a gold jewelry e-commerce platform by modeling products, attributes, and purchasable product variants using real-world domain-driven design principles.

How to Fix Common Next.js 16 Build Errors (Proxy, Dynamic Rendering & Revalidation)

Learn how to fix common Next.js 16 build errors, understand Proxy, Dynamic Rendering, and Revalidation, and improve your application's SEO and performance.

Why I Built cyber-ui: A Minimal Design System for Developers

How my projects, from backend systems to monitoring tools, led me to create a minimal and personal UI foundation.

End of note.