Django supports asynchronous views, allowing applications to handle operations such as network requests and database queries without blocking the event loop unnecessarily. Its asynchronous ORM methods also make it possible to retrieve, create, update, and delete records using await.
However, database transactions require special attention in asynchronous code. A transaction must keep related database operations together so that either all changes succeed or none of them are committed. Simply adding async to a view and placing transaction.atomic() around awaited ORM calls does not provide a reliable solution.
In Django versions where transactions are not supported directly in asynchronous code, the recommended approach is to move the complete transactional operation into a synchronous function and call it from the async view using sync_to_async().
This article explains how Django's asynchronous ORM works, how to handle transactions safely, and how to avoid common problems in applications that use asynchronous views.
Understanding Django's Async ORM
Django's asynchronous ORM provides asynchronous alternatives to many existing database operations. These methods generally use an a prefix.
For example, get() has the asynchronous counterpart aget(), while create() has acreate().
Consider a model representing customer accounts:
from django.db import models
class Account(models.Model):
owner = models.CharField(max_length=100)
balance = models.DecimalField(
max_digits=12,
decimal_places=2,
default=0
)
An asynchronous view can retrieve an account using aget():
from django.http import JsonResponse
from django.views.decorators.http import require_GET
from .models import Account
async def account_detail(request, account_id):
try:
account = await Account.objects.aget(pk=account_id)
except Account.DoesNotExist:
return JsonResponse(
{"error": "Account not found"},
status=404
)
return JsonResponse({
"id": account.id,
"owner": account.owner,
"balance": str(account.balance),
})
The await expression allows the coroutine to suspend while the database operation completes, rather than blocking the event loop in the same way as a synchronous database call.
Other commonly used asynchronous ORM operations include:
Synchronous operation | Asynchronous counterpart |
|---|
get()
| aget()
|
create()
| acreate()
|
first()
| afirst()
|
exists()
| aexists()
|
count()
| acount()
|
save()
| asave()
|
delete()
| adelete()
|
QuerySet iteration | async for
|
The exact methods available depend on the Django version and the operation being performed. Check the documentation for the version installed in your project before adopting a particular API.
Asynchronous ORM methods do not automatically make every database workflow non-blocking. Some operations still require synchronous execution, and transaction support must be considered separately.
Why Database Transactions Need Special Handling
A database transaction groups related operations into a single unit of work. If every operation succeeds, the transaction commits. If an operation fails, the transaction can roll back the changes.
For example, transferring money between two accounts involves at least three operations:
Deduct the amount from the source account.
Add the amount to the destination account.
Record the transfer.
These operations must succeed or fail together. If the source account is updated but the destination account update fails, the database must not retain a partially completed transfer.
Django provides transaction.atomic() for this purpose:
from django.db import transaction
with transaction.atomic():
# Perform related database operations.
pass
The important distinction is that transaction.atomic() is a synchronous transaction-management API. In Django versions where transactions are not supported directly in async mode, this pattern is not a safe way to group asynchronous ORM operations:
async def transfer_view(request):
with transaction.atomic():
source = await Account.objects.aget(pk=1)
# Additional awaited database operations.
The transaction context does not become asynchronous merely because the surrounding function is declared with async def.
Instead, keep the complete transaction inside a synchronous function and call that function from the asynchronous view.
Implementing a Transaction-Safe Async View
Let's build a simplified money-transfer example. It uses two models: Account stores balances, and Transfer records completed transfers.
Define the models
from django.db import models
class Account(models.Model):
owner = models.CharField(max_length=100)
balance = models.DecimalField(
max_digits=12,
decimal_places=2,
default=0
)
class Transfer(models.Model):
source = models.ForeignKey(
Account,
on_delete=models.PROTECT,
related_name="outgoing_transfers"
)
destination = models.ForeignKey(
Account,
on_delete=models.PROTECT,
related_name="incoming_transfers"
)
amount = models.DecimalField(
max_digits=12,
decimal_places=2
)
created_at = models.DateTimeField(auto_now_add=True)
The Transfer model keeps a record of the transaction. The PROTECT option prevents an account from being deleted while related transfer records still reference it.
After creating the models, generate and apply the migrations.
Create the synchronous transaction function
The next step is to place all database operations that belong to the transfer inside one synchronous function.
from decimal import Decimal
from django.db import transaction
from .models import Account, Transfer
def transfer_money_sync(source_id, destination_id, amount):
amount = Decimal(amount)
if amount <= 0:
raise ValueError("Transfer amount must be positive.")
if source_id == destination_id:
raise ValueError(
"Source and destination accounts must be different."
)
with transaction.atomic():
account_ids = sorted([source_id, destination_id])
accounts = Account.objects.select_for_update().filter(
pk__in=account_ids
).in_bulk()
if source_id not in accounts:
raise Account.DoesNotExist("Source account not found.")
if destination_id not in accounts:
raise Account.DoesNotExist(
"Destination account not found."
)
source = accounts[source_id]
destination = accounts[destination_id]
if source.balance < amount:
raise ValueError("Insufficient funds.")
source.balance -= amount
destination.balance += amount
source.save(update_fields=["balance"])
destination.save(update_fields=["balance"])
transfer = Transfer.objects.create(
source=source,
destination=destination,
amount=amount
)
return {
"status": "completed",
"transfer_id": transfer.id,
"amount": str(amount),
}
This function uses three important database features.
First, transaction.atomic() groups the balance updates and transfer record creation into one transaction. An exception during the operation causes the transaction to roll back.
Second, select_for_update() requests row-level locks on the selected account records. On supported databases, these locks help prevent concurrent transactions from modifying the same accounts in conflicting ways.
Third, the account IDs are sorted before retrieving the rows. Consistent lock ordering can reduce deadlock risk when concurrent transfers involve the same accounts. It does not eliminate every possible deadlock.
The code also validates that the amount is positive and that the accounts are different. In a production application, validate the amount before calling this function as well, and ensure it comes from a trusted decimal-parsing process rather than an unchecked floating-point value.
Call the transaction function from an async view
Use sync_to_async() from asgiref.sync to call the synchronous function without executing it directly in the async view.
from decimal import Decimal, InvalidOperation
from asgiref.sync import sync_to_async
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .models import Account
from .services import transfer_money_sync
@require_POST
async def transfer_view(request):
try:
source_id = int(request.POST["source_id"])
destination_id = int(request.POST["destination_id"])
amount = Decimal(request.POST["amount"])
if not amount.is_finite() or amount <= 0:
raise ValueError("Invalid transfer amount.")
result = await sync_to_async(
transfer_money_sync,
thread_sensitive=True
)(
source_id=source_id,
destination_id=destination_id,
amount=amount
)
return JsonResponse(result, status=200)
except (KeyError, ValueError, InvalidOperation):
return JsonResponse(
{"error": "Invalid transfer details."},
status=400
)
except Account.DoesNotExist:
return JsonResponse(
{"error": "An account was not found."},
status=404
)
In this example, services.py is a module in the Django application containing the transaction function.
The view parses the submitted values and invokes the synchronous transaction function with thread_sensitive=True. This is the standard approach for calling synchronous Django code from asynchronous code when thread-sensitive behavior matters.
The transaction begins and ends inside the synchronous function. No await expression is placed inside the transaction, and the function returns only after the database operation has completed.
The example assumes that the application has already implemented authentication, authorization, CSRF protection, and any required account-ownership checks. A real financial application also needs idempotency controls, appropriate auditing, and stronger business-rule validation.
Handling Errors and Transaction Rollbacks
A transaction protects database consistency, but it does not replace application-level error handling.
Suppose the destination account cannot be updated after the source account balance has been deducted. If the exception propagates out of the atomic() block, Django rolls back the transaction. The balance deduction and any transfer record created within that transaction are not committed.
The async view can catch the exception after the synchronous function returns control through sync_to_async().
However, avoid catching database exceptions inside an atomic() block and then continuing to issue queries as if the transaction were healthy. Depending on the exception and transaction state, Django may mark the transaction as needing rollback.
For expected business-rule failures, such as insufficient funds, raising a regular ValueError is straightforward. For database errors, let the exception leave the transaction boundary and handle it at an appropriate outer layer.
Also avoid returning detailed database error messages to clients. Log the technical details securely and return a suitable application-level response.
What About ATOMIC_REQUESTS?
Django's ATOMIC_REQUESTS setting wraps supported request processing in a transaction for each request. It can be useful for synchronous views, but it should not be assumed to provide transaction management for asynchronous views in Django versions where async transactions are unsupported.
For critical operations in async views, an explicit synchronous function containing transaction.atomic() makes the transaction boundary clear.
If your installed Django version adds or changes async transaction support, verify the official behavior for that version before changing this design. Do not assume that async ORM methods alone imply support for asynchronous transaction contexts.
Best Practices for Async ORM Transactions
Keep transactions short
Perform only the database operations required for the transaction inside transaction.atomic(). Avoid external HTTP requests, sending emails, or waiting for unrelated asynchronous work while holding database locks.
Long-running transactions increase lock contention and can reduce throughput under concurrent load.
Use consistent lock ordering
When multiple records need to be locked, acquire them in a consistent order where practical. This reduces some common deadlock scenarios, although the database can still detect and abort deadlocked transactions.
Applications should be prepared to handle appropriate database errors and retry operations when the business logic and database conditions make retries safe.
Handle external side effects carefully
Sending an email or publishing a message before a transaction commits can produce an inconsistency if the database later rolls back.
For simple post-commit work, Django provides transaction.on_commit():
from django.db import transaction
def update_order_sync(order_id):
with transaction.atomic():
# Update the order and related records.
pass
transaction.on_commit(
lambda: print(f"Order {order_id} committed.")
)
The callback runs after a successful commit. In production, replace the illustrative print() with the appropriate operation. For reliable message delivery across system failures, consider a transactional outbox pattern rather than relying solely on an in-process callback.
Test transaction behavior
Test both successful transfers and failure cases, including insufficient funds and missing accounts. Verify that failed operations leave the balances and transfer records unchanged.
Use TransactionTestCase when a test needs to exercise real transaction behavior and commit or rollback boundaries. Django's TestCase wraps tests in transactions for isolation, so its behavior can differ from a real request lifecycle. For asynchronous views, use the appropriate async test support in your installed Django version.
Common Mistakes to Avoid
Mistake | Why it causes problems | Better approach |
|---|
Calling synchronous ORM methods directly from an async view | Can raise SynchronousOnlyOperation | Use async ORM methods or a synchronous helper |
Placing awaited ORM calls inside transaction.atomic() | Does not provide supported async transaction management in affected versions | Put the complete transaction in a synchronous function |
Splitting one transaction across multiple sync_to_async() calls | Each call may run as a separate unit of work | Keep all dependent database operations in one helper |
Performing network requests inside a transaction | Holds database locks while waiting on another service | Make external calls outside the transaction |
Assuming row locks work identically on every database | Lock support and behavior vary by backend | Check database-specific behavior and test concurrency |
Ignoring concurrent requests | Can cause lost updates or conflicting operations | Use appropriate locking, constraints, and validation |
Advantages and Disadvantages of This Approach
The synchronous transaction helper provides a clear and reliable transaction boundary while allowing the surrounding request handler to remain asynchronous.
Its main advantages are:
It uses Django's established transaction-management behavior.
It keeps related database changes within one transaction.
It separates HTTP request handling from business logic.
It makes transaction behavior easier to test independently.
There are also trade-offs. The transaction function runs synchronously, so it occupies a worker thread while its database operations execute. Thread-sensitive execution can affect concurrency and throughput, especially when the function performs slow work or holds locks for too long.
For this reason, asynchronous views should not be treated as a guarantee that every part of the request is non-blocking. Use async ORM methods for supported independent operations, and use a synchronous transaction helper where the installed Django version requires it.
Summary
Django's asynchronous ORM makes it easier to perform database operations from async views, but transaction management requires a separate design decision.
For Django versions that do not support transactions directly in async mode, place all related database operations inside a synchronous function using transaction.atomic(). Call that function through sync_to_async() and keep the transaction short.
This approach provides a clear boundary for commits, rollbacks, and database locking. It also helps prevent partial updates when multiple database operations must succeed together.
Before implementing this pattern, check the transaction capabilities of your installed Django version and database backend. That verification is essential for building asynchronous applications that remain correct under concurrent requests and database failures.
Join the conversation! Your thoughts help the community grow.