skip to content

In pytest-django, what does @pytest.mark.django_db(transaction=True) change compared with the plain marker, and when is it worth the cost?

level: middleimportance: should knowfreq 42%

answer

  1. which Django case class underneath
  2. real commits
  3. flush instead of rollback
  4. reordered to run later

basics

~20 s

transaction=True runs the test like Django's TransactionTestCase: real commits instead of a rolled-back transaction, with tables flushed afterwards. It is slower, so use it only when the test needs committed data, such as on_commit behaviour or other connections.

solid answer

~40 s

The plain `@pytest.mark.django_db` (or the `db` fixture) runs the test the way Django's `TestCase` does: inside a transaction rolled back at the end, cheap and isolated. `@pytest.mark.django_db(transaction=True)`, or the `transactional_db` fixture, switches to `TransactionTestCase` behaviour: the code runs in autocommit, so `transaction.atomic()` blocks really commit, `on_commit` callbacks really fire, and other connections or threads can see the data; afterwards every table is flushed. That flush is what makes these tests much slower. Use it for code whose behaviour depends on a real commit: `on_commit` side effects without a capture helper, `select_for_update` across connections, `live_server` browser tests, or code that opens its own connection. `reset_sequences=True` implies transactional mode too. pytest-django also reorders tests so transactional ones run after the rest.

go deeper

for a junior

Know that transaction=True gives real commits and is slower than the default marker.

for a middle

Map the marker to TestCase versus TransactionTestCase, and explain what rollback hides: on_commit, other connections, row locks.

for a senior

Keep transactional tests rare and justified, prefer capturing on_commit callbacks where enough, and understand why the plugin reorders the suite.

for a principal

Balance suite speed against fidelity: which behaviours must be proven with real commits and how many such tests the pipeline can afford.

## Two isolation strategies behind one marker pytest-django does not invent its own database isolation. Behind the scenes it builds a small Django test case class for each database test and chooses the base class from the marker: | Declared by the test | Django behaviour used | Isolation mechanism | |---|---|---| | `@pytest.mark.django_db` or `db` | `TestCase` | transaction around the test, rolled back | | `django_db(transaction=True)` or `transactional_db` | `TransactionTestCase` | real commits, every table flushed after the test | | `django_db(reset_sequences=True)` | `TransactionTestCase` | as above, plus sequences reset before the test | If a test requests both `db` and `transactional_db`, the transactional one wins. The `live_server` fixture also forces transactional mode, because the server thread uses its own connection and can only see committed data. ## What the default marker cannot show you Inside the default mode the whole test runs in an outer transaction that never commits. That is fast and isolates tests well, but it hides some behaviour: 1. **`transaction.on_commit()` callbacks** never run on their own, because the outer transaction never commits. (Capturing them with the `django_capture_on_commit_callbacks` fixture is a cheaper alternative when you only need to run them.) 2. **Other connections** (a thread, a subprocess, a live server) cannot see uncommitted rows. 3. **Row locks and isolation-level behaviour**, such as `select_for_update()` contention between two connections, cannot be exercised by one connection inside one transaction. 4. **Code that manages transactions itself**, for example a function that relies on autocommit between steps, behaves differently when wrapped. ## What `transaction=True` costs - **Flushing** every table after each test is far slower than a rollback, and the cost grows with the number of tables. - Data created by migrations or by a session-level seeding fixture is **wiped** by the flush unless `serialized_rollback=True` restores it, which is slower still. - pytest-django **reorders the suite** like Django's runner: non-transactional database tests first, then transactional ones, then tests without database access, so a flush cannot remove data a rollback-based test expected. ## Other marker arguments - **`databases=["default", "analytics"]`** lets the test use more than the `default` alias; `databases="__all__"` allows all of them. - **`serialized_rollback=True`** restores the database's initial contents after a transactional test's flush. - **`reset_sequences=True`** makes primary keys start from the beginning, useful when a test asserts literal ids, and forces transactional mode. ```python import pytest from django.db import transaction from billing.models import Invoice from billing.services import charge_and_notify # registers an on_commit email @pytest.mark.django_db(transaction=True) def test_receipt_sent_after_commit(mailoutbox): invoice = Invoice.objects.create(amount_cents=500) with transaction.atomic(): charge_and_notify(invoice) assert len(mailoutbox) == 1 # the on_commit hook really ran ``` ## Choosing - Default to the plain marker; most model and view tests need nothing more. - Reach for `transaction=True` only when the assertion depends on a commit being real, and keep those tests few and focused. - Before adding it for `on_commit`, consider whether capturing the callbacks gives the same confidence at a fraction of the cost. ## Recognising the need in practice Symptoms that a test needs `transaction=True` rather than more mocking: - An email or task that should be triggered "after commit" never appears in `mailoutbox`, although the code path clearly ran. - A second connection (a thread, the live server, a subprocess) reports that a row created in the test does not exist. - A test of locking or retry logic always passes because nothing ever contends for the row. Symptoms that a transactional test is misplaced: - It checks only return values or rendered output, with no dependence on commit timing. - It is slow, and removing the argument leaves it passing. - It breaks tests that run after it because data loaded at session start has been flushed. A useful review question for every `transaction=True` in a pull request is simply: *which assertion would fail without a real commit?* If nobody can name one, the plain marker is the right choice. The same discipline applies to `reset_sequences=True`: a test that asserts literal primary keys is usually better rewritten to compare objects, which removes the need for a transactional, sequence-resetting test altogether.

  • Why does the live_server fixture force transactional behaviour in pytest-django?
    The live server handles requests in another thread with its own database connection. Rows created inside an uncommitted test transaction are invisible to that connection, so the test data must be really committed. pytest-django therefore treats any test using `live_server` like `transaction=True`, flushing tables afterwards.
  • Why does pytest-django run transactional tests after the other database tests?
    A transactional test ends by flushing every table, which removes data such as rows loaded once at session start. Running rollback-based tests first means they see the database in its expected initial state, mirroring the order Django's own runner uses for `TestCase` and `TransactionTestCase`.

The default marker is writing in pencil on a single sheet and erasing it after each test; transaction=True is writing in ink on the shared ledger, so others can read it, and then shredding and reprinting the whole ledger afterwards.

saying these in an interview costs you the question

  • Believing transaction=True wraps each test in an extra savepoint
  • Thinking on_commit callbacks fire under the plain django_db marker
  • Marking every database test transaction=True just to be safe
  • Saying a transactional test rolls back instead of flushing tables
  • Assuming the live_server fixture works with the plain rollback mode