[{"data":1,"prerenderedAt":1596},["ShallowReactive",2],{"page-\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002F":3},{"id":4,"title":5,"body":6,"description":1555,"extension":1556,"meta":1557,"navigation":243,"path":1592,"seo":1593,"stem":1594,"__hash__":1595},"content\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Findex.md","Database Fixtures & Transactional Tests",{"type":7,"value":8,"toc":1535},"minimark",[9,22,27,68,72,75,183,194,198,203,326,330,421,425,481,488,492,580,583,587,643,665,669,672,720,735,739,846,850,861,880,993,1004,1008,1011,1018,1073,1090,1100,1211,1215,1218,1236,1287,1296,1312,1316,1319,1336,1347,1358,1362,1365,1408,1414,1425,1436,1440,1452,1462,1471,1477,1486,1490,1524,1531],[10,11,12,13,17,18,21],"p",{},"A database test suite is fast and reliable, or it is neither. The difference is one design decision: whether a test leaves the database clean by ",[14,15,16],"em",{},"undoing"," what it did or by ",[14,19,20],{},"deleting"," what it finds. Undoing is a rollback — constant time, immune to mid-test failures, and correct regardless of what the test touched. Deleting is a cleanup routine that must know every table, respect every foreign key, and run even when the test raised, which it usually does not.",[23,24,26],"h2",{"id":25},"prerequisites","Prerequisites",[28,29,30,40,55,65],"ul",{},[31,32,33,34,39],"li",{},"A real database engine, ideally started by ",[35,36,38],"a",{"href":37},"\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002F","Testcontainers"," so every developer and every CI job gets the same version.",[31,41,42,46,47,50,51,54],{},[43,44,45],"code",{},"SQLAlchemy >= 2.0"," for ",[43,48,49],{},"join_transaction_mode",", or Django 4.2+ where ",[43,52,53],{},"TestCase"," provides the equivalent out of the box.",[31,56,57,60,61,64],{},[43,58,59],{},"pytest >= 8.0",", plus ",[43,62,63],{},"pytest-xdist"," if the suite runs in parallel.",[31,66,67],{},"Migrations that can be applied from empty, since the test database is built by running them.",[23,69,71],{"id":70},"core-concept-isolation-is-a-transaction-not-a-cleanup","Core concept: isolation is a transaction, not a cleanup",[10,73,74],{},"Every test needs to see a known starting state and leave no trace. There are three ways to achieve that and they differ by two orders of magnitude in cost.",[76,77,80,179],"figure",{"className":78},[79],"diagram",[81,82,89,90,89,94,89,98,89,106,89,116,89,125,89,131,89,135,89,142,89,147,89,151,89,155,89,160,89,166,89,170,89,174],"svg",{"viewBox":83,"role":84,"ariaLabelledBy":85,"xmlns":88},"0 0 840 268","img",[86,87],"iso-t","iso-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[91,92,93],"title",{"id":86},"Three isolation strategies compared by cost and safety",[95,96,97],"desc",{"id":87},"Three rows. Recreating the schema per test costs hundreds of milliseconds and is safe. Deleting rows in teardown costs tens of milliseconds and is unsafe because a mid-test failure skips it. Rolling back a transaction costs under a millisecond and is safe because the rollback runs in a finally block.",[99,100],"rect",{"x":101,"y":101,"width":102,"height":103,"rx":104,"fill":105},"0","840","268","14","#fffdf8",[107,108,115],"text",{"x":109,"y":110,"textAnchor":111,"fontSize":112,"fontWeight":113,"fill":114},"420","28","middle","16","700","#3d405b","How a test leaves the database clean",[99,117],{"x":110,"y":118,"width":119,"height":120,"rx":121,"fill":122,"stroke":123,"strokeWidth":124},"50","784","62","11","#fbe9e3","#e07a5f","1.8",[107,126,130],{"x":127,"y":128,"fontSize":129,"fontWeight":113,"fill":114},"48","74","12.5","recreate the schema",[107,132,134],{"x":127,"y":133,"fontSize":121,"fill":114},"94","correct, but DDL per test — the suite becomes setup with tests attached",[107,136,141],{"x":137,"y":138,"textAnchor":139,"fontSize":129,"fontWeight":113,"fill":140},"792","86","end","#8f3d22","200–800 ms",[99,143],{"x":110,"y":144,"width":119,"height":120,"rx":121,"fill":145,"stroke":146,"strokeWidth":124},"120","#f7f0da","#f2cc8f",[107,148,150],{"x":127,"y":149,"fontSize":129,"fontWeight":113,"fill":114},"144","delete rows in teardown",[107,152,154],{"x":127,"y":153,"fontSize":121,"fill":114},"164","skipped when a test raises; must know every table and foreign-key order",[107,156,159],{"x":137,"y":157,"textAnchor":139,"fontSize":129,"fontWeight":113,"fill":158},"156","#8a5a00","10–60 ms",[99,161],{"x":110,"y":162,"width":119,"height":120,"rx":121,"fill":163,"stroke":164,"strokeWidth":165},"190","#e6f0ea","#81b29a","2",[107,167,169],{"x":127,"y":168,"fontSize":129,"fontWeight":113,"fill":114},"214","roll back a transaction",[107,171,173],{"x":127,"y":172,"fontSize":121,"fill":114},"234","runs in finally, knows nothing about your tables, undoes everything",[107,175,178],{"x":137,"y":176,"textAnchor":139,"fontSize":129,"fontWeight":113,"fill":177},"226","#2a5f49","\u003C 1 ms",[180,181,182],"figcaption",{},"The rollback wins on both axes. Its only complication is what happens when the code under test commits, which is what savepoints exist for.",[10,184,185,186,189,190,193],{},"The complication is real, though. Application code calls ",[43,187,188],{},"session.commit()"," because that is what application code does, and a naive rollback fixture is defeated by the first commit. The resolution is to give the session a ",[14,191,192],{},"savepoint"," rather than the outer transaction: the application commits and releases its savepoint, the outer transaction is untouched, and the fixture rolls the whole thing back at teardown.",[23,195,197],{"id":196},"step-by-step-implementation","Step-by-step implementation",[199,200,202],"h3",{"id":201},"_1-one-engine-built-from-migrations","1. One engine, built from migrations",[204,205,210],"pre",{"className":206,"code":207,"language":208,"meta":209,"style":209},"language-python shiki shiki-themes github-light github-dark","import pytest\nfrom alembic import command\nfrom alembic.config import Config\nfrom sqlalchemy import create_engine\n\n\n@pytest.fixture(scope=\"session\")\ndef engine(postgres_dsn):\n    # One engine and one pool for the entire run.\n    engine = create_engine(postgres_dsn, pool_pre_ping=True)\n\n    # Build the schema by running the real migrations, so an unapplyable\n    # revision fails the suite immediately rather than at deploy time.\n    alembic_cfg = Config(\"alembic.ini\")\n    alembic_cfg.set_main_option(\"sqlalchemy.url\", postgres_dsn)\n    command.upgrade(alembic_cfg, \"head\")\n\n    yield engine\n    engine.dispose()\n","python","",[43,211,212,220,226,232,238,245,250,256,262,268,274,279,285,291,297,303,309,314,320],{"__ignoreMap":209},[213,214,217],"span",{"class":215,"line":216},"line",1,[213,218,219],{},"import pytest\n",[213,221,223],{"class":215,"line":222},2,[213,224,225],{},"from alembic import command\n",[213,227,229],{"class":215,"line":228},3,[213,230,231],{},"from alembic.config import Config\n",[213,233,235],{"class":215,"line":234},4,[213,236,237],{},"from sqlalchemy import create_engine\n",[213,239,241],{"class":215,"line":240},5,[213,242,244],{"emptyLinePlaceholder":243},true,"\n",[213,246,248],{"class":215,"line":247},6,[213,249,244],{"emptyLinePlaceholder":243},[213,251,253],{"class":215,"line":252},7,[213,254,255],{},"@pytest.fixture(scope=\"session\")\n",[213,257,259],{"class":215,"line":258},8,[213,260,261],{},"def engine(postgres_dsn):\n",[213,263,265],{"class":215,"line":264},9,[213,266,267],{},"    # One engine and one pool for the entire run.\n",[213,269,271],{"class":215,"line":270},10,[213,272,273],{},"    engine = create_engine(postgres_dsn, pool_pre_ping=True)\n",[213,275,277],{"class":215,"line":276},11,[213,278,244],{"emptyLinePlaceholder":243},[213,280,282],{"class":215,"line":281},12,[213,283,284],{},"    # Build the schema by running the real migrations, so an unapplyable\n",[213,286,288],{"class":215,"line":287},13,[213,289,290],{},"    # revision fails the suite immediately rather than at deploy time.\n",[213,292,294],{"class":215,"line":293},14,[213,295,296],{},"    alembic_cfg = Config(\"alembic.ini\")\n",[213,298,300],{"class":215,"line":299},15,[213,301,302],{},"    alembic_cfg.set_main_option(\"sqlalchemy.url\", postgres_dsn)\n",[213,304,306],{"class":215,"line":305},16,[213,307,308],{},"    command.upgrade(alembic_cfg, \"head\")\n",[213,310,312],{"class":215,"line":311},17,[213,313,244],{"emptyLinePlaceholder":243},[213,315,317],{"class":215,"line":316},18,[213,318,319],{},"    yield engine\n",[213,321,323],{"class":215,"line":322},19,[213,324,325],{},"    engine.dispose()\n",[199,327,329],{"id":328},"_2-a-transaction-per-test-with-a-savepoint-inside-it","2. A transaction per test, with a savepoint inside it",[204,331,333],{"className":206,"code":332,"language":208,"meta":209,"style":209},"import pytest\nfrom sqlalchemy.orm import Session\n\n\n@pytest.fixture\ndef db_session(engine):\n    connection = engine.connect()\n    outer = connection.begin()\n\n    # The session runs inside a SAVEPOINT of the outer transaction, so\n    # session.commit() releases the savepoint and never touches `outer`.\n    session = Session(bind=connection, join_transaction_mode=\"create_savepoint\")\n    try:\n        yield session\n    finally:\n        session.close()\n        outer.rollback()      # undoes every commit the test made\n        connection.close()\n",[43,334,335,339,344,348,352,357,362,367,372,376,381,386,391,396,401,406,411,416],{"__ignoreMap":209},[213,336,337],{"class":215,"line":216},[213,338,219],{},[213,340,341],{"class":215,"line":222},[213,342,343],{},"from sqlalchemy.orm import Session\n",[213,345,346],{"class":215,"line":228},[213,347,244],{"emptyLinePlaceholder":243},[213,349,350],{"class":215,"line":234},[213,351,244],{"emptyLinePlaceholder":243},[213,353,354],{"class":215,"line":240},[213,355,356],{},"@pytest.fixture\n",[213,358,359],{"class":215,"line":247},[213,360,361],{},"def db_session(engine):\n",[213,363,364],{"class":215,"line":252},[213,365,366],{},"    connection = engine.connect()\n",[213,368,369],{"class":215,"line":258},[213,370,371],{},"    outer = connection.begin()\n",[213,373,374],{"class":215,"line":264},[213,375,244],{"emptyLinePlaceholder":243},[213,377,378],{"class":215,"line":270},[213,379,380],{},"    # The session runs inside a SAVEPOINT of the outer transaction, so\n",[213,382,383],{"class":215,"line":276},[213,384,385],{},"    # session.commit() releases the savepoint and never touches `outer`.\n",[213,387,388],{"class":215,"line":281},[213,389,390],{},"    session = Session(bind=connection, join_transaction_mode=\"create_savepoint\")\n",[213,392,393],{"class":215,"line":287},[213,394,395],{},"    try:\n",[213,397,398],{"class":215,"line":293},[213,399,400],{},"        yield session\n",[213,402,403],{"class":215,"line":299},[213,404,405],{},"    finally:\n",[213,407,408],{"class":215,"line":305},[213,409,410],{},"        session.close()\n",[213,412,413],{"class":215,"line":311},[213,414,415],{},"        outer.rollback()      # undoes every commit the test made\n",[213,417,418],{"class":215,"line":316},[213,419,420],{},"        connection.close()\n",[199,422,424],{"id":423},"_3-seed-reference-data-once-outside-the-rolled-back-transaction","3. Seed reference data once, outside the rolled-back transaction",[204,426,428],{"className":206,"code":427,"language":208,"meta":209,"style":209},"import pytest\nfrom sqlalchemy.orm import Session\n\n\n@pytest.fixture(scope=\"session\", autouse=True)\ndef reference_data(engine):\n    # Currencies, countries, feature flags: immutable rows every test assumes.\n    # Committed for real, once, so they survive each test's rollback.\n    with Session(engine) as session:\n        session.add_all([Currency(code=\"GBP\"), Currency(code=\"USD\")])\n        session.commit()\n",[43,429,430,434,438,442,446,451,456,461,466,471,476],{"__ignoreMap":209},[213,431,432],{"class":215,"line":216},[213,433,219],{},[213,435,436],{"class":215,"line":222},[213,437,343],{},[213,439,440],{"class":215,"line":228},[213,441,244],{"emptyLinePlaceholder":243},[213,443,444],{"class":215,"line":234},[213,445,244],{"emptyLinePlaceholder":243},[213,447,448],{"class":215,"line":240},[213,449,450],{},"@pytest.fixture(scope=\"session\", autouse=True)\n",[213,452,453],{"class":215,"line":247},[213,454,455],{},"def reference_data(engine):\n",[213,457,458],{"class":215,"line":252},[213,459,460],{},"    # Currencies, countries, feature flags: immutable rows every test assumes.\n",[213,462,463],{"class":215,"line":258},[213,464,465],{},"    # Committed for real, once, so they survive each test's rollback.\n",[213,467,468],{"class":215,"line":264},[213,469,470],{},"    with Session(engine) as session:\n",[213,472,473],{"class":215,"line":270},[213,474,475],{},"        session.add_all([Currency(code=\"GBP\"), Currency(code=\"USD\")])\n",[213,477,478],{"class":215,"line":276},[213,479,480],{},"        session.commit()\n",[10,482,483,484,487],{},"Reference data must be committed outside the per-test transaction, or every test would have to re-create it. The rule that keeps this safe is that reference data is ",[14,485,486],{},"immutable",": a test that modifies a currency row breaks every later test, so such rows should be treated as read-only by convention and, where the engine supports it, by permission.",[199,489,491],{"id":490},"_4-isolate-parallel-workers","4. Isolate parallel workers",[204,493,495],{"className":206,"code":494,"language":208,"meta":209,"style":209},"import os\n\nimport pytest\nfrom sqlalchemy import create_engine, text\n\n\n@pytest.fixture(scope=\"session\")\ndef postgres_dsn(base_dsn):\n    worker = os.environ.get(\"PYTEST_XDIST_WORKER\", \"master\")   # \"gw0\", \"gw1\", …\n    dbname = f\"test_{worker}\"\n\n    admin = create_engine(base_dsn, isolation_level=\"AUTOCOMMIT\")\n    with admin.connect() as conn:\n        conn.execute(text(f'DROP DATABASE IF EXISTS \"{dbname}\"'))\n        conn.execute(text(f'CREATE DATABASE \"{dbname}\"'))\n    admin.dispose()\n\n    return base_dsn.rsplit(\"\u002F\", 1)[0] + f\"\u002F{dbname}\"\n",[43,496,497,502,506,510,515,519,523,527,532,537,542,546,551,556,561,566,571,575],{"__ignoreMap":209},[213,498,499],{"class":215,"line":216},[213,500,501],{},"import os\n",[213,503,504],{"class":215,"line":222},[213,505,244],{"emptyLinePlaceholder":243},[213,507,508],{"class":215,"line":228},[213,509,219],{},[213,511,512],{"class":215,"line":234},[213,513,514],{},"from sqlalchemy import create_engine, text\n",[213,516,517],{"class":215,"line":240},[213,518,244],{"emptyLinePlaceholder":243},[213,520,521],{"class":215,"line":247},[213,522,244],{"emptyLinePlaceholder":243},[213,524,525],{"class":215,"line":252},[213,526,255],{},[213,528,529],{"class":215,"line":258},[213,530,531],{},"def postgres_dsn(base_dsn):\n",[213,533,534],{"class":215,"line":264},[213,535,536],{},"    worker = os.environ.get(\"PYTEST_XDIST_WORKER\", \"master\")   # \"gw0\", \"gw1\", …\n",[213,538,539],{"class":215,"line":270},[213,540,541],{},"    dbname = f\"test_{worker}\"\n",[213,543,544],{"class":215,"line":276},[213,545,244],{"emptyLinePlaceholder":243},[213,547,548],{"class":215,"line":281},[213,549,550],{},"    admin = create_engine(base_dsn, isolation_level=\"AUTOCOMMIT\")\n",[213,552,553],{"class":215,"line":287},[213,554,555],{},"    with admin.connect() as conn:\n",[213,557,558],{"class":215,"line":293},[213,559,560],{},"        conn.execute(text(f'DROP DATABASE IF EXISTS \"{dbname}\"'))\n",[213,562,563],{"class":215,"line":299},[213,564,565],{},"        conn.execute(text(f'CREATE DATABASE \"{dbname}\"'))\n",[213,567,568],{"class":215,"line":305},[213,569,570],{},"    admin.dispose()\n",[213,572,573],{"class":215,"line":311},[213,574,244],{"emptyLinePlaceholder":243},[213,576,577],{"class":215,"line":316},[213,578,579],{},"    return base_dsn.rsplit(\"\u002F\", 1)[0] + f\"\u002F{dbname}\"\n",[10,581,582],{},"Creating one database per worker costs well under a second and eliminates an entire class of failure — two workers truncating the same table, one worker's rollback racing another's read — that otherwise presents as intermittent application bugs.",[199,584,586],{"id":585},"_5-djangos-equivalent","5. Django's equivalent",[204,588,590],{"className":206,"code":589,"language":208,"meta":209,"style":209},"import pytest\nfrom django.test import TestCase\n\n\nclass OrderTests(TestCase):     # not SimpleTestCase, not TransactionTestCase\n    \"\"\"TestCase wraps each test method in an atomic block and rolls it back.\"\"\"\n\n    def test_total_excludes_cancelled_lines(self):\n        order = Order.objects.create(customer=self.customer)\n        OrderLine.objects.create(order=order, amount=100, cancelled=True)\n        self.assertEqual(order.total(), 0)\n",[43,591,592,596,601,605,609,614,619,623,628,633,638],{"__ignoreMap":209},[213,593,594],{"class":215,"line":216},[213,595,219],{},[213,597,598],{"class":215,"line":222},[213,599,600],{},"from django.test import TestCase\n",[213,602,603],{"class":215,"line":228},[213,604,244],{"emptyLinePlaceholder":243},[213,606,607],{"class":215,"line":234},[213,608,244],{"emptyLinePlaceholder":243},[213,610,611],{"class":215,"line":240},[213,612,613],{},"class OrderTests(TestCase):     # not SimpleTestCase, not TransactionTestCase\n",[213,615,616],{"class":215,"line":247},[213,617,618],{},"    \"\"\"TestCase wraps each test method in an atomic block and rolls it back.\"\"\"\n",[213,620,621],{"class":215,"line":252},[213,622,244],{"emptyLinePlaceholder":243},[213,624,625],{"class":215,"line":258},[213,626,627],{},"    def test_total_excludes_cancelled_lines(self):\n",[213,629,630],{"class":215,"line":264},[213,631,632],{},"        order = Order.objects.create(customer=self.customer)\n",[213,634,635],{"class":215,"line":270},[213,636,637],{},"        OrderLine.objects.create(order=order, amount=100, cancelled=True)\n",[213,639,640],{"class":215,"line":276},[213,641,642],{},"        self.assertEqual(order.total(), 0)\n",[10,644,645,646,648,649,652,653,656,657,660,661,664],{},"Django's ",[43,647,53],{}," already implements exactly the pattern above; the class you choose is the whole configuration. ",[43,650,651],{},"TransactionTestCase"," disables it and truncates tables instead, which is roughly fifty times slower and only necessary for tests that genuinely need committed data visible to another connection. With ",[43,654,655],{},"pytest-django",", ",[43,658,659],{},"@pytest.mark.django_db"," gives the rollback behaviour and ",[43,662,663],{},"@pytest.mark.django_db(transaction=True)"," opts out of it.",[23,666,668],{"id":667},"verification","Verification",[10,670,671],{},"The fixture is correct when a test that commits leaves nothing behind. Prove it with a pair of tests that must both pass in either order:",[204,673,675],{"className":206,"code":674,"language":208,"meta":209,"style":209},"def test_commits_are_rolled_back(db_session):\n    db_session.add(Widget(name=\"leaked\"))\n    db_session.commit()                       # a real commit, inside the savepoint\n    assert db_session.query(Widget).count() == 1\n\n\ndef test_sees_a_clean_database(db_session):\n    # If this fails, the previous test's commit escaped the fixture.\n    assert db_session.query(Widget).count() == 0\n",[43,676,677,682,687,692,697,701,705,710,715],{"__ignoreMap":209},[213,678,679],{"class":215,"line":216},[213,680,681],{},"def test_commits_are_rolled_back(db_session):\n",[213,683,684],{"class":215,"line":222},[213,685,686],{},"    db_session.add(Widget(name=\"leaked\"))\n",[213,688,689],{"class":215,"line":228},[213,690,691],{},"    db_session.commit()                       # a real commit, inside the savepoint\n",[213,693,694],{"class":215,"line":234},[213,695,696],{},"    assert db_session.query(Widget).count() == 1\n",[213,698,699],{"class":215,"line":240},[213,700,244],{"emptyLinePlaceholder":243},[213,702,703],{"class":215,"line":247},[213,704,244],{"emptyLinePlaceholder":243},[213,706,707],{"class":215,"line":252},[213,708,709],{},"def test_sees_a_clean_database(db_session):\n",[213,711,712],{"class":215,"line":258},[213,713,714],{},"    # If this fails, the previous test's commit escaped the fixture.\n",[213,716,717],{"class":215,"line":264},[213,718,719],{},"    assert db_session.query(Widget).count() == 0\n",[10,721,722,723,726,727,730,731,734],{},"Then run them in both orders — ",[43,724,725],{},"pytest -p no:randomly"," for the declared order and ",[43,728,729],{},"pytest -p randomly"," for a shuffled one — and confirm both pass. This two-test pair is worth keeping permanently; it fails loudly the day someone changes the fixture to use ",[43,732,733],{},"engine"," directly instead of a bound connection.",[23,736,738],{"id":737},"troubleshooting","Troubleshooting",[740,741,742,758],"table",{},[743,744,745],"thead",{},[746,747,748,752,755],"tr",{},[749,750,751],"th",{},"Symptom",[749,753,754],{},"Root cause",[749,756,757],{},"Fix",[759,760,761,776,796,813,824,835],"tbody",{},[746,762,763,767,770],{},[764,765,766],"td",{},"Rows survive between tests",[764,768,769],{},"Session bound to the engine, not the connection",[764,771,772,773],{},"Bind ",[43,774,775],{},"Session(bind=connection)",[746,777,778,783,790],{},[764,779,780],{},[43,781,782],{},"InvalidRequestError: transaction already deassociated",[764,784,785,786,789],{},"Application called ",[43,787,788],{},"commit()"," without a savepoint",[764,791,792,793],{},"Set ",[43,794,795],{},"join_transaction_mode=\"create_savepoint\"",[746,797,798,804,807],{},[764,799,800,801],{},"Deadlocks under ",[43,802,803],{},"-n auto",[764,805,806],{},"Workers sharing one database",[764,808,809,810],{},"One database per ",[43,811,812],{},"PYTEST_XDIST_WORKER",[746,814,815,818,821],{},[764,816,817],{},"Reference data missing after the first test",[764,819,820],{},"Seeded inside the rolled-back transaction",[764,822,823],{},"Seed in a session-scoped fixture committed on its own connection",[746,825,826,829,832],{},[764,827,828],{},"DDL in a test breaks the outer transaction",[764,830,831],{},"MySQL commits DDL implicitly",[764,833,834],{},"Mark those tests; give them a disposable database",[746,836,837,840,843],{},[764,838,839],{},"First test slow, rest fast",[764,841,842],{},"Pool warm-up plus migrations, as designed",[764,844,845],{},"Nothing to fix; keep the engine session-scoped",[23,847,849],{"id":848},"what-a-savepoint-actually-does","What a savepoint actually does",[10,851,852,853,856,857,860],{},"Understanding the mechanism removes most of the guesswork. A savepoint is a named marker inside an open transaction. ",[43,854,855],{},"ROLLBACK TO SAVEPOINT s"," undoes everything after the marker while leaving the transaction open; ",[43,858,859],{},"RELEASE SAVEPOINT s"," discards the marker and keeps the work, subject to the outer transaction's eventual fate.",[10,862,863,864,867,868,871,872,875,876,879],{},"That last clause is the whole trick. When the ORM session \"commits\", SQLAlchemy issues ",[43,865,866],{},"RELEASE SAVEPOINT",", not ",[43,869,870],{},"COMMIT",". The application's data is now durable ",[14,873,874],{},"within the outer transaction",", so subsequent queries in the same test see it — which is what makes the test realistic. But the outer transaction has never been committed, so the fixture's final ",[43,877,878],{},"ROLLBACK"," discards all of it.",[76,881,883,983],{"className":882},[79],[81,884,89,889,89,892,89,895,89,912,89,915,89,918,89,926,89,930,89,937,89,943,89,947,89,949,89,953,89,956,89,960,89,963,89,965,89,968,89,973,89,979],{"viewBox":885,"role":84,"ariaLabelledBy":886,"xmlns":88},"0 0 840 280",[887,888],"sp-t","sp-d",[91,890,891],{"id":887},"Savepoint nesting inside the fixture's outer transaction",[95,893,894],{"id":888},"A timeline inside one connection. The fixture issues BEGIN, then the session opens a savepoint. The application inserts rows and commits, which issues RELEASE SAVEPOINT so the rows remain visible. A second savepoint and commit follow. At teardown the fixture issues ROLLBACK on the outer transaction and every row disappears.",[896,897,898,899,89],"defs",{},"\n    ",[900,901,908],"marker",{"id":902,"viewBox":903,"refX":904,"refY":905,"markerWidth":906,"markerHeight":906,"orient":907},"sp-a","0 0 10 10","9","5","7","auto-start-reverse",[909,910],"path",{"d":911,"fill":114},"M0 0 L10 5 L0 10 z",[99,913],{"x":101,"y":101,"width":102,"height":914,"rx":104,"fill":105},"280",[107,916,917],{"x":109,"y":110,"textAnchor":111,"fontSize":112,"fontWeight":113,"fill":114},"The application commits; the fixture still rolls back",[99,919],{"x":920,"y":921,"width":922,"height":923,"rx":924,"fill":925,"stroke":114,"strokeWidth":124},"30","52","780","150","12","#f4f1de",[107,927,929],{"x":118,"y":928,"fontSize":129,"fontWeight":113,"fill":114},"76","BEGIN — the fixture's outer transaction",[99,931],{"x":932,"y":933,"width":934,"height":935,"rx":936,"fill":163,"stroke":164,"strokeWidth":124},"60","90","330","96","10",[107,938,942],{"x":939,"y":940,"fontSize":941,"fontWeight":113,"fill":114},"78","114","11.5","SAVEPOINT sa_1",[107,944,946],{"x":939,"y":945,"fontSize":121,"fill":114},"136","INSERT INTO widget …",[107,948,188],{"x":939,"y":157,"fontSize":121,"fill":114},[107,950,952],{"x":939,"y":951,"fontSize":121,"fill":177},"176","→ RELEASE SAVEPOINT sa_1",[99,954],{"x":955,"y":933,"width":934,"height":935,"rx":936,"fill":163,"stroke":164,"strokeWidth":124},"410",[107,957,959],{"x":958,"y":940,"fontSize":941,"fontWeight":113,"fill":114},"428","SAVEPOINT sa_2",[107,961,962],{"x":958,"y":945,"fontSize":121,"fill":114},"UPDATE widget SET …",[107,964,188],{"x":958,"y":157,"fontSize":121,"fill":114},[107,966,967],{"x":958,"y":951,"fontSize":121,"fill":177},"→ RELEASE SAVEPOINT sa_2",[215,969],{"x1":109,"y1":970,"x2":109,"y2":971,"stroke":114,"strokeWidth":124,"markerEnd":972},"206","228","url(#sp-a)",[99,974],{"x":975,"y":976,"width":977,"height":978,"rx":904,"fill":122,"stroke":123,"strokeWidth":165},"220","232","400","38",[107,980,982],{"x":109,"y":981,"textAnchor":111,"fontSize":129,"fontWeight":113,"fill":114},"256","ROLLBACK — every row above disappears",[180,984,985,986,989,990,992],{},"Because the outer ",[43,987,988],{},"BEGIN"," was never matched by a ",[43,991,870],{},", the database has no record that any of it happened.",[10,994,995,996,999,1000,1003],{},"Two limits follow from the mechanism. A second ",[14,997,998],{},"connection"," — a background thread, a subprocess, a live server running in another process — cannot see uncommitted data, so tests that exercise cross-connection visibility need real commits and therefore real cleanup. And engines that do not support transactional DDL, notably MySQL, will implicitly commit on ",[43,1001,1002],{},"CREATE TABLE",", silently ending the outer transaction and leaving everything before it durable.",[23,1005,1007],{"id":1006},"when-a-rollback-is-not-enough","When a rollback is not enough",[10,1009,1010],{},"Three situations defeat the transactional fixture, and each has a specific remedy that does not require abandoning the pattern for the rest of the suite.",[10,1012,1013,1017],{},[1014,1015,1016],"strong",{},"A second connection must see the data."," A live server fixture, a background worker thread, or a subprocess running the CLI all open their own connections, and an uncommitted transaction is invisible across connections by definition. These tests need real commits and therefore real cleanup. Keep them few, mark them, and give them a truncation fixture of their own:",[204,1019,1021],{"className":206,"code":1020,"language":208,"meta":209,"style":209},"import pytest\nfrom sqlalchemy import text\n\n\n@pytest.fixture\ndef committed_db(engine):\n    \"\"\"For the handful of tests where another connection must see the rows.\"\"\"\n    yield engine\n    with engine.begin() as conn:\n        # One statement, foreign keys handled by CASCADE, reference data excluded.\n        conn.execute(text(\"TRUNCATE TABLE order_line, \\\"order\\\", widget CASCADE\"))\n",[43,1022,1023,1027,1032,1036,1040,1044,1049,1054,1058,1063,1068],{"__ignoreMap":209},[213,1024,1025],{"class":215,"line":216},[213,1026,219],{},[213,1028,1029],{"class":215,"line":222},[213,1030,1031],{},"from sqlalchemy import text\n",[213,1033,1034],{"class":215,"line":228},[213,1035,244],{"emptyLinePlaceholder":243},[213,1037,1038],{"class":215,"line":234},[213,1039,244],{"emptyLinePlaceholder":243},[213,1041,1042],{"class":215,"line":240},[213,1043,356],{},[213,1045,1046],{"class":215,"line":247},[213,1047,1048],{},"def committed_db(engine):\n",[213,1050,1051],{"class":215,"line":252},[213,1052,1053],{},"    \"\"\"For the handful of tests where another connection must see the rows.\"\"\"\n",[213,1055,1056],{"class":215,"line":258},[213,1057,319],{},[213,1059,1060],{"class":215,"line":264},[213,1061,1062],{},"    with engine.begin() as conn:\n",[213,1064,1065],{"class":215,"line":270},[213,1066,1067],{},"        # One statement, foreign keys handled by CASCADE, reference data excluded.\n",[213,1069,1070],{"class":215,"line":276},[213,1071,1072],{},"        conn.execute(text(\"TRUNCATE TABLE order_line, \\\"order\\\", widget CASCADE\"))\n",[10,1074,1075,1078,1079,656,1082,1085,1086,1089],{},[1014,1076,1077],{},"The engine does not roll back DDL."," MySQL commits implicitly on ",[43,1080,1081],{},"CREATE",[43,1083,1084],{},"ALTER"," and ",[43,1087,1088],{},"DROP",", which silently ends the outer transaction and makes everything before it durable. A test that creates a temporary table therefore poisons the ones after it. The remedy is a marker plus a dedicated database recreated for those tests; the alternative — dropping transactional isolation everywhere because MySQL cannot support it in one case — costs the whole suite its speed.",[10,1091,1092,1095,1096,1099],{},[1014,1093,1094],{},"The test asserts on isolation behaviour itself."," Verifying that two concurrent transactions produce a serialization failure, or that a ",[43,1097,1098],{},"SELECT FOR UPDATE"," actually blocks, requires two genuine connections with genuine transactions. Those tests are worth writing and they cannot use the fixture; they belong in their own module with explicit connection management and a timeout, since a mis-written lock test is a deadlocked suite.",[76,1101,1103,1208],{"className":1102},[79],[81,1104,89,1109,89,1112,89,1115,89,1122,89,1126,89,1129,89,1134,89,1137,89,1143,89,1147,89,1151,89,1155,89,1161,89,1166,89,1171,89,1174,89,1177,89,1180,89,1184,89,1188,89,1191,89,1194,89,1200,89,1204],{"viewBox":1105,"role":84,"ariaLabelledBy":1106,"xmlns":88},"0 0 800 250",[1107,1108],"notenough-t","notenough-d",[91,1110,1111],{"id":1107},"Which isolation strategy each kind of test needs",[95,1113,1114],{"id":1108},"A decision path. If another connection must observe the data, or the engine commits DDL implicitly, or the test asserts on isolation semantics, use committed data with explicit truncation. Otherwise the transactional fixture applies, which covers the large majority of tests.",[896,1116,898,1117,89],{},[900,1118,1120],{"id":1119,"viewBox":903,"refX":904,"refY":905,"markerWidth":906,"markerHeight":906,"orient":907},"notenough-a",[909,1121],{"d":911,"fill":114},[99,1123],{"x":101,"y":101,"width":1124,"height":1125,"rx":104,"fill":105},"800","250",[107,1127,1128],{"x":977,"y":110,"textAnchor":111,"fontSize":112,"fontWeight":113,"fill":114},"Rollback by default; commit by exception",[99,1130],{"x":1125,"y":127,"width":1131,"height":1132,"rx":936,"fill":925,"stroke":114,"strokeWidth":1133},"300","46","1.6",[107,1135,1136],{"x":977,"y":928,"textAnchor":111,"fontSize":924,"fill":114},"Does anything outside this connection look?",[215,1138],{"x1":1125,"y1":1139,"x2":1140,"y2":1141,"stroke":114,"strokeWidth":1133,"markerEnd":1142},"71","160","112","url(#notenough-a)",[107,1144,1146],{"x":1145,"y":933,"fontSize":121,"fill":140},"186","yes",[215,1148],{"x1":1149,"y1":1139,"x2":1150,"y2":1141,"stroke":114,"strokeWidth":1133,"markerEnd":1142},"550","640",[107,1152,1154],{"x":1153,"y":933,"fontSize":121,"fill":177},"596","no",[99,1156],{"x":1157,"y":1158,"width":1159,"height":1160,"rx":121,"fill":122,"stroke":123,"strokeWidth":165},"26","116","270","108",[107,1162,1165],{"x":1163,"y":1164,"textAnchor":111,"fontSize":129,"fontWeight":113,"fill":114},"161","142","commit + truncate",[107,1167,1170],{"x":1168,"y":1169,"fontSize":121,"fill":114},"44","166","• live server or subprocess",[107,1172,1173],{"x":1168,"y":1145,"fontSize":121,"fill":114},"• MySQL DDL in the test",[107,1175,1176],{"x":1168,"y":970,"fontSize":121,"fill":114},"• isolation-level assertions",[99,1178],{"x":1179,"y":1158,"width":1159,"height":1160,"rx":121,"fill":163,"stroke":164,"strokeWidth":165},"504",[107,1181,1183],{"x":1182,"y":1164,"textAnchor":111,"fontSize":129,"fontWeight":113,"fill":114},"639","transactional fixture",[107,1185,1187],{"x":1186,"y":1169,"fontSize":121,"fill":114},"522","• everything else",[107,1189,1190],{"x":1186,"y":1145,"fontSize":121,"fill":114},"• sub-millisecond teardown",[107,1192,1193],{"x":1186,"y":970,"fontSize":121,"fill":177},"• typically 95% of tests",[99,1195],{"x":1196,"y":923,"width":1197,"height":932,"rx":936,"fill":105,"stroke":1198,"strokeWidth":1199},"316","168","rgba(61,64,91,0.35)","1.5",[107,1201,1203],{"x":977,"y":1202,"textAnchor":111,"fontSize":121,"fill":114},"174","mark the exceptions",[107,1205,1207],{"x":977,"y":1206,"textAnchor":111,"fontSize":121,"fill":114},"194","so the split stays visible",[180,1209,1210],{},"The exceptions are real but rare. Marking them keeps the cost visible and stops the truncation fixture spreading to tests that never needed it.",[23,1212,1214],{"id":1213},"layering-fixtures-so-the-suite-stays-readable","Layering fixtures so the suite stays readable",[10,1216,1217],{},"A mature database suite ends up with four layers, and naming them explicitly prevents the single-fixture sprawl that makes later tests hard to read.",[10,1219,1220,1221,1223,1224,1227,1228,1231,1232,1235],{},"At the bottom is the ",[1014,1222,733],{},", session-scoped, built from migrations, shared by everything. Above it the ",[1014,1225,1226],{},"session",", function-scoped, transactional, the only thing tests are given directly. Above that, ",[1014,1229,1230],{},"factories"," — not fixtures at all, but callables a test invokes to build exactly the rows it needs. And at the top, a small number of ",[1014,1233,1234],{},"scenario fixtures"," that compose factories for a situation used by many tests, such as \"a customer with an open subscription\".",[204,1237,1239],{"className":206,"code":1238,"language":208,"meta":209,"style":209},"import pytest\n\n\n@pytest.fixture\ndef subscribed_customer(db_session):\n    \"\"\"A composed scenario — one obvious meaning, used by many tests.\"\"\"\n    customer = CustomerFactory(country=\"GB\")\n    SubscriptionFactory(customer=customer, status=\"active\")\n    db_session.flush()          # assign primary keys without committing\n    return customer\n",[43,1240,1241,1245,1249,1253,1257,1262,1267,1272,1277,1282],{"__ignoreMap":209},[213,1242,1243],{"class":215,"line":216},[213,1244,219],{},[213,1246,1247],{"class":215,"line":222},[213,1248,244],{"emptyLinePlaceholder":243},[213,1250,1251],{"class":215,"line":228},[213,1252,244],{"emptyLinePlaceholder":243},[213,1254,1255],{"class":215,"line":234},[213,1256,356],{},[213,1258,1259],{"class":215,"line":240},[213,1260,1261],{},"def subscribed_customer(db_session):\n",[213,1263,1264],{"class":215,"line":247},[213,1265,1266],{},"    \"\"\"A composed scenario — one obvious meaning, used by many tests.\"\"\"\n",[213,1268,1269],{"class":215,"line":252},[213,1270,1271],{},"    customer = CustomerFactory(country=\"GB\")\n",[213,1273,1274],{"class":215,"line":258},[213,1275,1276],{},"    SubscriptionFactory(customer=customer, status=\"active\")\n",[213,1278,1279],{"class":215,"line":264},[213,1280,1281],{},"    db_session.flush()          # assign primary keys without committing\n",[213,1283,1284],{"class":215,"line":270},[213,1285,1286],{},"    return customer\n",[10,1288,1289,1292,1293,1295],{},[43,1290,1291],{},"flush()"," rather than ",[43,1294,788],{}," is the detail that keeps scenario fixtures compatible with the rollback: it sends the INSERTs so identifiers are populated and later queries see the rows, without ending any transaction.",[10,1297,1298,1299,1302,1303,1306,1307,1311],{},"The discipline that keeps this layering useful is to resist adding fields to a scenario fixture for one test's benefit. When a test needs a customer with a lapsed subscription, it builds one from the factories rather than adding a ",[43,1300,1301],{},"status"," parameter to ",[43,1304,1305],{},"subscribed_customer","; the parametrised scenario fixture that tries to serve every case is how a suite acquires a fixture nobody can safely change. Each layer should be replaceable without touching the ones above it, which is the same separation of construction from configuration argued for in ",[35,1308,1310],{"href":1309},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fdependency-injection-for-testability\u002Fwiring-test-doubles-through-a-factory-function\u002F","wiring test doubles through a factory function",".",[23,1313,1315],{"id":1314},"keeping-the-fixture-honest-as-the-suite-grows","Keeping the fixture honest as the suite grows",[10,1317,1318],{},"Two habits prevent this design from eroding over the following year.",[10,1320,1321,1322,1324,1325,1327,1328,1331,1332,1335],{},"The first is a guard against direct engine use. Any fixture or helper that takes ",[43,1323,733],{}," and opens its own connection bypasses the outer transaction, and the resulting leakage appears as a mysteriously order-dependent test somewhere else entirely. Making ",[43,1326,733],{}," private to the ",[43,1329,1330],{},"conftest.py"," that defines ",[43,1333,1334],{},"db_session",", and exposing only the session, removes the temptation.",[10,1337,1338,1339,1342,1343,1311],{},"The second is a periodic check that the migrations and the models still agree. Because the test database is built from migrations, a model change with no matching revision produces a failure at the first query rather than a silent divergence — but only if nobody has quietly switched the fixture back to ",[43,1340,1341],{},"Base.metadata.create_all()"," for speed. A test that autogenerates a revision and asserts it is empty makes the invariant explicit, and the full setup is in ",[35,1344,1346],{"href":1345},"\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Ftesting-alembic-migrations-in-ci\u002F","testing Alembic migrations in CI",[10,1348,1349,1350,1353,1354,1311],{},"Finally, resist the pull toward a shared \"kitchen sink\" fixture that creates a customer, an order and three products for every test. It makes the suite slower, couples unrelated tests to one data shape, and hides which values a given test actually depends on. Explicit per-test construction through ",[35,1351,1230],{"href":1352},"\u002Fintegration-database-and-service-testing\u002Ftest-data-factories-and-builders\u002F"," costs a line or two and makes each test readable on its own — the same argument made for fixture design generally in ",[35,1355,1357],{"href":1356},"\u002Fadvanced-pytest-architecture-configuration\u002Fmastering-pytest-fixtures\u002Ftaming-autouse-fixtures-in-large-suites\u002F","taming autouse fixtures in large suites",[23,1359,1361],{"id":1360},"measuring-what-the-change-bought","Measuring what the change bought",[10,1363,1364],{},"Restructuring a database suite is worth measuring, both to confirm the gain and to catch the regression when someone reintroduces a per-test truncation.",[204,1366,1370],{"className":1367,"code":1368,"language":1369,"meta":209,"style":209},"language-bash shiki shiki-themes github-light github-dark","# Before and after, same machine, same database, same tests.\npytest tests\u002Fdb -q --durations=0 | tail -n 40\n","bash",[43,1371,1372,1378],{"__ignoreMap":209},[213,1373,1374],{"class":215,"line":216},[213,1375,1377],{"class":1376},"sJ8bj","# Before and after, same machine, same database, same tests.\n",[213,1379,1380,1384,1388,1392,1395,1399,1402,1405],{"class":215,"line":222},[213,1381,1383],{"class":1382},"sScJk","pytest",[213,1385,1387],{"class":1386},"sZZnC"," tests\u002Fdb",[213,1389,1391],{"class":1390},"sj4cs"," -q",[213,1393,1394],{"class":1390}," --durations=0",[213,1396,1398],{"class":1397},"szBVR"," |",[213,1400,1401],{"class":1382}," tail",[213,1403,1404],{"class":1390}," -n",[213,1406,1407],{"class":1390}," 40\n",[10,1409,1410,1411,1413],{},"The number to watch is the ratio of setup time to call time. A healthy transactional suite spends almost nothing in setup — the connection is already open, the transaction is a single ",[43,1412,988],{}," — so the durations report is dominated by the call phase, where the actual queries run. A suite still doing per-test cleanup shows the opposite: setup and teardown together outweighing the test bodies by three or four to one.",[10,1415,1416,1417,1420,1421,1424],{},"Two other numbers are worth recording in the same pass. Total wall clock for the database subset, since that is what people feel; and peak database connection count during the run, which is what breaks first when the suite is parallelised. ",[43,1418,1419],{},"SELECT count(*) FROM pg_stat_activity"," sampled during a run gives the second, and if it is anywhere near the server's ",[43,1422,1423],{},"max_connections"," divided by the worker count, the pool is sized too generously for test use.",[10,1426,1427,1428,1431,1432,1311],{},"Keep the figures in the repository next to the fixture, as a comment or a short note. The value is not the benchmark itself but the record of intent: the next engineer who wonders why the engine is session-scoped and the session is not finds the answer rather than re-deriving it, and the one who is tempted to add a ",[43,1429,1430],{},"TRUNCATE"," to \"make sure things are clean\" sees what that costs before doing it. That is the same reasoning behind recording enforcement thresholds rather than leaving them to habit, as in ",[35,1433,1435],{"href":1434},"\u002Fadvanced-pytest-architecture-configuration\u002Fcoverage-measurement-and-enforcement\u002F","coverage measurement and enforcement",[23,1437,1439],{"id":1438},"frequently-asked-questions","Frequently Asked Questions",[10,1441,1442,1445,1446,1448,1449,1451],{},[1014,1443,1444],{},"Why does my rollback fixture stop working when the code calls commit()?","\nBecause the application's commit ends the transaction your fixture intended to roll back. The fix is to run the session inside a savepoint: SQLAlchemy 2.0's ",[43,1447,795],{}," does this directly, and Django's ",[43,1450,53],{}," wraps each test in an atomic block for the same reason. The application then commits a savepoint rather than the outer transaction.",[10,1453,1454,1457,1458,1461],{},[1014,1455,1456],{},"Should the test database be built from migrations or from metadata?","\nFrom migrations. Building with ",[43,1459,1460],{},"create_all()"," means the migrations are never executed until a deploy, so drift between models and migration history goes undetected. Running migrations once per session costs a few seconds and turns every suite run into a smoke test of the migration chain.",[10,1463,1464,1467,1468,1470],{},[1014,1465,1466],{},"How do I isolate parallel xdist workers from each other?","\nGive each worker its own database or schema, named from ",[43,1469,812],{},". Sharing one database across workers means one worker's rollback races another's read, producing failures that look like application bugs. Creating eight small databases at session start costs a second and removes the whole class of problem.",[10,1472,1473,1476],{},[1014,1474,1475],{},"Is SQLite an acceptable stand-in for Postgres in tests?","\nOnly for code that touches no database-specific behaviour, which in practice is almost nothing. SQLite differs in type affinity, constraint enforcement timing, concurrency, JSON operators and window-function support, so a suite that passes on SQLite and deploys to Postgres is testing a different program. Run the real engine in a container.",[10,1478,1479,1482,1483,1485],{},[1014,1480,1481],{},"What about tests that need DDL, which cannot be rolled back everywhere?","\nPostgres is transactional for DDL, so ",[43,1484,1002],{}," inside a test rolls back cleanly. MySQL is not: DDL commits implicitly and destroys the surrounding transaction. For MySQL, mark those tests and give them a dedicated database that is recreated between them, rather than weakening isolation for the whole suite.",[23,1487,1489],{"id":1488},"related-guides","Related guides",[28,1491,1492,1499,1504,1511,1517],{},[31,1493,1494,1495,1311],{},"Implement the rollback fixture end to end in ",[35,1496,1498],{"href":1497},"\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Frolling-back-every-test-with-nested-transactions\u002F","rolling back every test with nested transactions",[31,1500,1501,1502,1311],{},"Keep models and schema in step with ",[35,1503,1346],{"href":1345},[31,1505,1506,1507,1311],{},"Start the engine the fixture connects to using ",[35,1508,1510],{"href":1509},"\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Fstarting-postgres-with-testcontainers-python\u002F","starting Postgres with testcontainers-python",[31,1512,1513,1514,1311],{},"Make each test's preconditions explicit with ",[35,1515,1516],{"href":1352},"test data factories and builders",[31,1518,1519,1520,1311],{},"For async engines, match the fixture's loop to the pool's using ",[35,1521,1523],{"href":1522},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002F","pytest-asyncio in depth",[10,1525,1526,1527],{},"← Back to ",[35,1528,1530],{"href":1529},"\u002Fintegration-database-and-service-testing\u002F","Integration, Database & Service Testing",[1532,1533,1534],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}",{"title":209,"searchDepth":222,"depth":222,"links":1536},[1537,1538,1539,1546,1547,1548,1549,1550,1551,1552,1553,1554],{"id":25,"depth":222,"text":26},{"id":70,"depth":222,"text":71},{"id":196,"depth":222,"text":197,"children":1540},[1541,1542,1543,1544,1545],{"id":201,"depth":228,"text":202},{"id":328,"depth":228,"text":329},{"id":423,"depth":228,"text":424},{"id":490,"depth":228,"text":491},{"id":585,"depth":228,"text":586},{"id":667,"depth":222,"text":668},{"id":737,"depth":222,"text":738},{"id":848,"depth":222,"text":849},{"id":1006,"depth":222,"text":1007},{"id":1213,"depth":222,"text":1214},{"id":1314,"depth":222,"text":1315},{"id":1360,"depth":222,"text":1361},{"id":1438,"depth":222,"text":1439},{"id":1488,"depth":222,"text":1489},"Per-test database isolation without per-test setup: savepoint rollbacks in SQLAlchemy and Django, session-scoped engines, parallel-safe schemas, and seeding that stays put.","md",{"slug":1558,"type":1559,"breadcrumb":1560,"datePublished":1561,"dateModified":1561,"faq":1562,"howto":1573},"database-fixtures-and-transactional-tests","topic","Database Fixtures","2026-09-18",[1563,1565,1567,1569,1571],{"q":1444,"a":1564},"Because the application's commit ends the transaction your fixture intended to roll back. The fix is to run the session inside a savepoint: SQLAlchemy 2.0's join_transaction_mode='create_savepoint' does this directly, and Django's TestCase wraps each test in an atomic block for the same reason. The application then commits a savepoint rather than the outer transaction.",{"q":1456,"a":1566},"From migrations. Building with create_all() means the migrations are never executed until a deploy, so drift between models and migration history goes undetected. Running migrations once per session costs a few seconds and turns every suite run into a smoke test of the migration chain.",{"q":1466,"a":1568},"Give each worker its own database or schema, named from PYTEST_XDIST_WORKER. Sharing one database across workers means one worker's rollback races another's read, producing failures that look like application bugs. Creating eight small databases at session start costs a second and removes the whole class of problem.",{"q":1475,"a":1570},"Only for code that touches no database-specific behaviour, which in practice is almost nothing. SQLite differs in type affinity, constraint enforcement timing, concurrency, JSON operators and window-function support, so a suite that passes on SQLite and deploys to Postgres is testing a different program. Run the real engine in a container.",{"q":1481,"a":1572},"Postgres is transactional for DDL, so CREATE TABLE inside a test rolls back cleanly. MySQL is not: DDL commits implicitly and destroys the surrounding transaction. For MySQL, mark those tests and give them a dedicated database that is recreated between them, rather than weakening isolation for the whole suite.",{"name":1574,"description":1575,"steps":1576},"How to build a transactional database fixture","Create the engine once, wrap each test in a transaction that rolls back, and keep the application's own commits inside a savepoint.",[1577,1580,1583,1586,1589],{"name":1578,"text":1579},"Create the engine at session scope","Build one engine and connection pool for the whole run, pointing at a database created by running the project's migrations.",{"name":1581,"text":1582},"Open an outer transaction per test","Take a connection from the pool, begin a transaction on it, and bind the ORM session to that connection rather than to the engine.",{"name":1584,"text":1585},"Run the session inside a savepoint","Set join_transaction_mode to create_savepoint so application commits release a savepoint instead of ending the outer transaction.",{"name":1587,"text":1588},"Roll back in a finally block","Close the session and roll back the outer transaction in a finally so the database is clean whether the test passed, failed or raised.",{"name":1590,"text":1591},"Isolate parallel workers","Derive the database name from PYTEST_XDIST_WORKER so concurrent workers never share mutable state.","\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests",{"title":5,"description":1555},"integration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Findex","IvSpgtLjGmJBgCFAUAUzAN2peOMoCMsInQJ4Ooop7N4",1789718765719]