2025, Sep 26 05:00

How to keep database isolation in async Django tests with pytest-django using sync_to_async

Learn why async Django tests leak DB rows with pytest-django and how to restore isolation using sync_to_async instead of transaction=True. Steps inside.

Async Django tests with database access often surprise even seasoned teams: rows created in one test bleed into the next, despite using pytest-django’s django_db marker. If your WebSocket or ASGI stack relies on async paths, this guide lays out why that happens and how to fix it without resorting to slower full-transaction runs.

Reproducing the issue

The following tests use pytest-django and pytest-asyncio. The expectation is simple: within a test, data is visible; across tests, it’s cleaned up by rollback. That is what django_db is supposed to guarantee in the default mode.

import pytest
from cameras.models import CameraGroup as GroupUnit
@pytest.mark.django_db
@pytest.mark.asyncio
class AsyncSuite:
    async def test_a(self):  # OK
        await GroupUnit.objects.acreate(name="alpha1")
        assert await GroupUnit.objects.acount() == 1
    async def test_b(self):  # FAILS
        # Should not see test_a's record if rollback applied
        assert await GroupUnit.objects.acount() == 0
@pytest.mark.django_db(transaction=True)
@pytest.mark.asyncio
class AsyncSuiteTxn:
    async def test_a(self):  # OK
        await GroupUnit.objects.acreate(name="alpha2")
        assert await GroupUnit.objects.acount() == 1
    async def test_b(self):  # OK
        # Here isolation is kept, but tests are slower
        assert await GroupUnit.objects.acount() == 0
@pytest.mark.django_db
class SyncSuite:
    def test_a(self):  # OK
        GroupUnit.objects.create(name="alpha3")
        assert GroupUnit.objects.count() == 1
    def test_b(self):  # OK
        # Sync path keeps isolation via rollback
        assert GroupUnit.objects.count() == 0

What’s going on

The behavior flips between sync and async code paths. In synchronous tests, django_db mirrors django.test.TestCase and wraps each test in a transaction that rolls back at the end. In async tests, the same assumption does not hold.

Transactions do not yet work in async mode. If you have a piece of code that needs transactions behavior, we recommend you write that piece as a single synchronous function and call it using sync_to_async().

That means transactional isolation—the safety net that clears your changes between tests—won’t kick in for purely async ORM calls. As a result, objects created via acreate and related async queryset methods can persist and show up in subsequent tests. Using django_db(transaction=True) does pass, but it commits real changes and cleans up more slowly, which increases total runtime.

The fix that preserves isolation without turning on full transactions

Run the part that needs transactional semantics in a synchronous function and call it from your async test with asgiref.sync.sync_to_async. Django’s own async tests also rely on sync_to_async and async_to_sync in strategic places.

import pytest
from asgiref.sync import sync_to_async, async_to_sync
from cameras.models import CameraGroup as GroupUnit
@pytest.mark.django_db
@pytest.mark.asyncio
class AsyncSuiteFixed:
    async def test_a(self):
        # ORM calls executed synchronously to benefit from transactional rollback
        await sync_to_async(GroupUnit.objects.create)(name="alpha1")
        assert await sync_to_async(GroupUnit.objects.count)() == 1
    async def test_b(self):
        # Isolation is restored because previous changes were inside a sync transaction
        assert await sync_to_async(GroupUnit.objects.count)() == 0
# Alternatively, you can flip the direction in select places
# by running a whole async test body through async_to_sync.
# @async_to_sync
# async def test_bulk_like_case():
#     ... use sync_to_async(...) for ORM touches here ...

Why this matters

Async test suites without proper isolation are fragile. Data leakage across tests causes intermittent failures, masks real defects, and slows debugging. For teams validating WebSocket flows or other ASGI-driven logic, getting the Django ORM to participate in reliable test isolation is essential for determinism and speed.

Practical takeaways

If you need transactional behavior in async tests, run the database-interacting parts synchronously via sync_to_async. This preserves the rollback semantics you get in synchronous tests without paying the performance cost of running with transaction=True for the whole class. If you prefer a simpler path and can afford longer runtimes, enabling django_db(transaction=True) also works, as noted above, but expect slower execution because it performs real commits.

Either way, be deliberate about where your ORM calls execute. Keep the pieces that must rely on transactions inside a synchronous boundary, and call that boundary from your async tests with sync_to_async. This keeps tests fast, isolated, and predictable.

The article is based on a question from StackOverflow by RobBlanchard and an answer by r_2009.