[{"data":1,"prerenderedAt":1186},["ShallowReactive",2],{"page-\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Frolling-back-every-test-with-nested-transactions\u002F":3},{"id":4,"title":5,"body":6,"description":1149,"extension":1150,"meta":1151,"navigation":94,"path":1182,"seo":1183,"stem":1184,"__hash__":1185},"content\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Frolling-back-every-test-with-nested-transactions\u002Findex.md","Rolling Back Every Test with Nested Transactions",{"type":7,"value":8,"toc":1137},"minimark",[9,18,23,56,60,230,273,386,390,405,408,412,462,466,469,488,495,512,603,609,685,696,700,703,808,818,822,825,870,938,945,1045,1049,1055,1058,1062,1068,1085,1091,1095,1128,1133],[10,11,12,13,17],"p",{},"Application code commits. It calls ",[14,15,16],"code",{},"session.commit()"," at the end of a unit of work because that is what application code is supposed to do, and a naive rollback fixture is defeated by the first one: the commit ends the fixture's transaction, the rows become durable, and the next test inherits them. The fix is to give the session a savepoint rather than the outer transaction, so the application's commit releases the savepoint and the fixture's rollback still undoes everything.",[19,20,22],"h2",{"id":21},"prerequisites","Prerequisites",[24,25,26,41,44],"ul",{},[27,28,29,32,33,36,37,40],"li",{},[14,30,31],{},"SQLAlchemy >= 2.0",", which introduced ",[14,34,35],{},"join_transaction_mode",". On 1.4 the same effect needs a manual ",[14,38,39],{},"after_transaction_end"," listener.",[27,42,43],{},"A database engine with transactional semantics for the statements under test; Postgres qualifies, including DDL.",[27,45,46,49,50,55],{},[14,47,48],{},"pytest >= 8.0",", and the layering described in ",[51,52,54],"a",{"href":53},"\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002F","database fixtures and transactional tests",".",[19,57,59],{"id":58},"solution","Solution",[61,62,67],"pre",{"className":63,"code":64,"language":65,"meta":66,"style":66},"language-python shiki shiki-themes github-light github-dark","import pytest\nfrom sqlalchemy import create_engine\nfrom sqlalchemy.orm import Session\n\n\n@pytest.fixture(scope=\"session\")\ndef engine(postgres_dsn):\n    engine = create_engine(postgres_dsn, pool_pre_ping=True)\n    yield engine\n    engine.dispose()\n\n\n@pytest.fixture\ndef db_session(engine):\n    # One connection, one outer transaction — the thing we will roll back.\n    connection = engine.connect()\n    outer = connection.begin()\n\n    # Bound to THAT connection, not the engine, and joined via savepoints:\n    # session.commit() now issues RELEASE SAVEPOINT rather than COMMIT.\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","python","",[14,68,69,77,83,89,96,101,107,113,119,125,131,136,141,147,153,159,165,171,176,182,188,194,200,206,212,218,224],{"__ignoreMap":66},[70,71,74],"span",{"class":72,"line":73},"line",1,[70,75,76],{},"import pytest\n",[70,78,80],{"class":72,"line":79},2,[70,81,82],{},"from sqlalchemy import create_engine\n",[70,84,86],{"class":72,"line":85},3,[70,87,88],{},"from sqlalchemy.orm import Session\n",[70,90,92],{"class":72,"line":91},4,[70,93,95],{"emptyLinePlaceholder":94},true,"\n",[70,97,99],{"class":72,"line":98},5,[70,100,95],{"emptyLinePlaceholder":94},[70,102,104],{"class":72,"line":103},6,[70,105,106],{},"@pytest.fixture(scope=\"session\")\n",[70,108,110],{"class":72,"line":109},7,[70,111,112],{},"def engine(postgres_dsn):\n",[70,114,116],{"class":72,"line":115},8,[70,117,118],{},"    engine = create_engine(postgres_dsn, pool_pre_ping=True)\n",[70,120,122],{"class":72,"line":121},9,[70,123,124],{},"    yield engine\n",[70,126,128],{"class":72,"line":127},10,[70,129,130],{},"    engine.dispose()\n",[70,132,134],{"class":72,"line":133},11,[70,135,95],{"emptyLinePlaceholder":94},[70,137,139],{"class":72,"line":138},12,[70,140,95],{"emptyLinePlaceholder":94},[70,142,144],{"class":72,"line":143},13,[70,145,146],{},"@pytest.fixture\n",[70,148,150],{"class":72,"line":149},14,[70,151,152],{},"def db_session(engine):\n",[70,154,156],{"class":72,"line":155},15,[70,157,158],{},"    # One connection, one outer transaction — the thing we will roll back.\n",[70,160,162],{"class":72,"line":161},16,[70,163,164],{},"    connection = engine.connect()\n",[70,166,168],{"class":72,"line":167},17,[70,169,170],{},"    outer = connection.begin()\n",[70,172,174],{"class":72,"line":173},18,[70,175,95],{"emptyLinePlaceholder":94},[70,177,179],{"class":72,"line":178},19,[70,180,181],{},"    # Bound to THAT connection, not the engine, and joined via savepoints:\n",[70,183,185],{"class":72,"line":184},20,[70,186,187],{},"    # session.commit() now issues RELEASE SAVEPOINT rather than COMMIT.\n",[70,189,191],{"class":72,"line":190},21,[70,192,193],{},"    session = Session(bind=connection, join_transaction_mode=\"create_savepoint\")\n",[70,195,197],{"class":72,"line":196},22,[70,198,199],{},"    try:\n",[70,201,203],{"class":72,"line":202},23,[70,204,205],{},"        yield session\n",[70,207,209],{"class":72,"line":208},24,[70,210,211],{},"    finally:\n",[70,213,215],{"class":72,"line":214},25,[70,216,217],{},"        session.close()\n",[70,219,221],{"class":72,"line":220},26,[70,222,223],{},"        outer.rollback()           # undoes every \"commit\" the test made\n",[70,225,227],{"class":72,"line":226},27,[70,228,229],{},"        connection.close()\n",[61,231,233],{"className":63,"code":232,"language":65,"meta":66,"style":66},"def test_order_is_persisted(db_session):\n    create_order(db_session, customer_id=\"cus_1\", total=1234)   # commits inside\n    assert db_session.query(Order).count() == 1\n\n\ndef test_database_is_clean(db_session):\n    # Passes in either order only if the previous test's commit was contained.\n    assert db_session.query(Order).count() == 0\n",[14,234,235,240,245,250,254,258,263,268],{"__ignoreMap":66},[70,236,237],{"class":72,"line":73},[70,238,239],{},"def test_order_is_persisted(db_session):\n",[70,241,242],{"class":72,"line":79},[70,243,244],{},"    create_order(db_session, customer_id=\"cus_1\", total=1234)   # commits inside\n",[70,246,247],{"class":72,"line":85},[70,248,249],{},"    assert db_session.query(Order).count() == 1\n",[70,251,252],{"class":72,"line":91},[70,253,95],{"emptyLinePlaceholder":94},[70,255,256],{"class":72,"line":98},[70,257,95],{"emptyLinePlaceholder":94},[70,259,260],{"class":72,"line":103},[70,261,262],{},"def test_database_is_clean(db_session):\n",[70,264,265],{"class":72,"line":109},[70,266,267],{},"    # Passes in either order only if the previous test's commit was contained.\n",[70,269,270],{"class":72,"line":115},[70,271,272],{},"    assert db_session.query(Order).count() == 0\n",[274,275,278,378],"figure",{"className":276},[277],"diagram",[279,280,287,288,287,292,287,296,287,304,287,314,287,324,287,330,287,336,287,340,287,344,287,348,287,353,287,358,287,362,287,365,287,368,287,371,287,374],"svg",{"viewBox":281,"role":282,"ariaLabelledBy":283,"xmlns":286},"0 0 820 262","img",[284,285],"nr-t","nr-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[289,290,291],"title",{"id":284},"Engine-bound versus connection-bound sessions",[293,294,295],"desc",{"id":285},"Two arrangements. A session bound to the engine checks out its own connection from the pool, so its commit is a real commit outside the fixture's transaction and the rows survive. A session bound to the fixture's connection with savepoints runs inside the outer transaction, so its commit releases a savepoint and the fixture's rollback removes everything.",[297,298],"rect",{"x":299,"y":299,"width":300,"height":301,"rx":302,"fill":303},"0","820","262","14","#fffdf8",[305,306,313],"text",{"x":307,"y":308,"textAnchor":309,"fontSize":310,"fontWeight":311,"fill":312},"410","28","middle","16","700","#3d405b","The binding decides whether the rollback means anything",[297,315],{"x":316,"y":317,"width":318,"height":319,"rx":320,"fill":321,"stroke":322,"strokeWidth":323},"26","52","368","186","12","#fbe9e3","#e07a5f","2",[305,325,329],{"x":326,"y":327,"textAnchor":309,"fontSize":328,"fontWeight":311,"fill":312},"210","78","12.5","Session(bind=engine)",[305,331,335],{"x":332,"y":333,"fontSize":334,"fill":312},"44","106","11","fixture: connection A, BEGIN",[305,337,339],{"x":332,"y":338,"fontSize":334,"fill":312},"128","session: checks out connection B",[305,341,343],{"x":332,"y":342,"fontSize":334,"fill":312},"150","commit on B → really committed",[305,345,347],{"x":332,"y":346,"fontSize":334,"fill":312},"172","rollback on A → nothing to undo",[305,349,352],{"x":332,"y":350,"fontSize":334,"fontWeight":311,"fill":351},"206","#8f3d22","rows leak into the next test",[297,354],{"x":355,"y":317,"width":318,"height":319,"rx":320,"fill":356,"stroke":357,"strokeWidth":323},"426","#e6f0ea","#81b29a",[305,359,361],{"x":360,"y":327,"textAnchor":309,"fontSize":328,"fontWeight":311,"fill":312},"610","Session(bind=connection, savepoint)",[305,363,335],{"x":364,"y":333,"fontSize":334,"fill":312},"444",[305,366,367],{"x":364,"y":338,"fontSize":334,"fill":312},"session: SAVEPOINT on A",[305,369,370],{"x":364,"y":342,"fontSize":334,"fill":312},"commit → RELEASE SAVEPOINT",[305,372,373],{"x":364,"y":346,"fontSize":334,"fill":312},"rollback on A → undoes it all",[305,375,377],{"x":364,"y":350,"fontSize":334,"fontWeight":311,"fill":376},"#2a5f49","every test starts clean",[379,380,381,382,385],"figcaption",{},"The left-hand arrangement looks correct and is the one most often found in older ",[14,383,384],{},"conftest.py"," files. Its failure appears as order dependence, far from the fixture.",[19,387,389],{"id":388},"why-this-works","Why this works",[10,391,392,393,396,397,400,401,404],{},"A savepoint is a named marker inside an open transaction. ",[14,394,395],{},"RELEASE SAVEPOINT"," discards the marker and keeps the work — but only as far as the enclosing transaction's eventual fate. With ",[14,398,399],{},"join_transaction_mode=\"create_savepoint\"",", SQLAlchemy opens a savepoint whenever the session begins its own transaction on a connection that already has one, and translates ",[14,402,403],{},"commit()"," into a release. The application sees normal commit semantics: later queries in the same test see the committed rows. The database sees a transaction that was never committed and is finally rolled back.",[10,406,407],{},"Binding to the connection is the half that is easy to get wrong. A session bound to the engine asks the pool for a connection, gets a different one, and commits there — entirely outside the fixture's transaction.",[19,409,411],{"id":410},"edge-cases-and-failure-modes","Edge cases and failure modes",[24,413,414,424,430,440,449],{},[27,415,416,423],{},[417,418,419,420,55],"strong",{},"Code that creates its own ",[14,421,422],{},"Session(engine)"," It escapes the fixture. Inject the session, or point the application's session factory at the fixture's connection in tests.",[27,425,426,429],{},[417,427,428],{},"A second connection must see the data."," A live server or a background thread uses its own connection, and uncommitted data is invisible across connections by definition. Those tests need real commits and a truncation fixture.",[27,431,432,435,436,439],{},[417,433,434],{},"DDL on MySQL."," ",[14,437,438],{},"CREATE TABLE"," commits implicitly and ends the outer transaction. Postgres does not have this problem.",[27,441,442,448],{},[417,443,444,447],{},[14,445,446],{},"session.rollback()"," inside the application."," It rolls back to the savepoint, which is correct, and the session opens a new savepoint for subsequent work.",[27,450,451,454,455,457,458,461],{},[417,452,453],{},"Legacy 1.4 recipes."," The ",[14,456,39],{}," listener that restarted a nested transaction is no longer needed on 2.0 and can conflict with ",[14,459,460],{},"create_savepoint",". Delete it.",[19,463,465],{"id":464},"what-the-sql-actually-looks-like","What the SQL actually looks like",[10,467,468],{},"Turning on statement logging for one test makes the mechanism concrete, and it is the fastest way to confirm a fixture is doing what it claims.",[61,470,472],{"className":63,"code":471,"language":65,"meta":66,"style":66},"import logging\n\nlogging.getLogger(\"sqlalchemy.engine\").setLevel(logging.INFO)\n",[14,473,474,479,483],{"__ignoreMap":66},[70,475,476],{"class":72,"line":73},[70,477,478],{},"import logging\n",[70,480,481],{"class":72,"line":79},[70,482,95],{"emptyLinePlaceholder":94},[70,484,485],{"class":72,"line":85},[70,486,487],{},"logging.getLogger(\"sqlalchemy.engine\").setLevel(logging.INFO)\n",[61,489,493],{"className":490,"code":492,"language":305,"meta":66},[491],"language-text","BEGIN (implicit)                                  -- fixture: outer transaction\nSAVEPOINT sa_savepoint_1                          -- session joins via savepoint\nINSERT INTO \"order\" (customer_id, total) VALUES ('cus_1', 1234)\nRELEASE SAVEPOINT sa_savepoint_1                  -- application's commit()\nSAVEPOINT sa_savepoint_2                          -- next unit of work\nSELECT count(*) FROM \"order\"                      -- sees the committed row\nRELEASE SAVEPOINT sa_savepoint_2\nROLLBACK                                          -- fixture teardown: all gone\n",[14,494,492],{"__ignoreMap":66},[10,496,497,498,501,502,504,505,508,509,511],{},"Every ",[14,499,500],{},"COMMIT"," the application thought it issued is a ",[14,503,395],{},", and the only transaction-ending statement is the final ",[14,506,507],{},"ROLLBACK",". If the log shows a bare ",[14,510,500],{}," anywhere, something opened a connection outside the fixture, and the log line immediately above it usually names the query that did it.",[274,513,515,597],{"className":514},[277],[279,516,287,521,287,524,287,527,287,531,287,536,287,544,287,550,287,555,287,560,287,564,287,568,287,572,287,575,287,578,287,581,287,584,287,587,287,593],{"viewBox":517,"role":282,"ariaLabelledBy":518,"xmlns":286},"0 0 800 250",[519,520],"sql-t","sql-d",[289,522,523],{"id":519},"Statements issued across one test",[293,525,526],{"id":520},"A vertical sequence of SQL statements inside one outer transaction. Two savepoints open and are released as the application commits twice, while queries in between see the committed rows. The final statement is a rollback of the outer transaction, which removes everything the test wrote.",[297,528],{"x":299,"y":299,"width":529,"height":530,"rx":302,"fill":303},"800","250",[305,532,535],{"x":533,"y":308,"textAnchor":309,"fontSize":534,"fontWeight":311,"fill":312},"400","15.5","Two \"commits\", zero COMMIT statements",[297,537],{"x":538,"y":539,"width":540,"height":541,"rx":320,"fill":542,"stroke":312,"strokeWidth":543},"34","46","732","152","#f4f1de","1.8",[305,545,549],{"x":546,"y":547,"fontSize":548,"fontWeight":311,"fill":312},"54","68","11.5","BEGIN — outer transaction, owned by the fixture",[297,551],{"x":552,"y":327,"width":553,"height":333,"rx":554,"fill":356,"stroke":357,"strokeWidth":543},"60","330","10",[305,556,559],{"x":557,"y":558,"fontSize":334,"fill":312},"76","100","SAVEPOINT sa_1",[305,561,563],{"x":557,"y":562,"fontSize":334,"fill":312},"122","INSERT INTO \"order\" …",[305,565,567],{"x":557,"y":566,"fontSize":334,"fill":376},"144","RELEASE SAVEPOINT sa_1",[305,569,571],{"x":557,"y":570,"fontSize":334,"fill":312},"166","← application commit()",[297,573],{"x":307,"y":327,"width":574,"height":333,"rx":554,"fill":356,"stroke":357,"strokeWidth":543},"340",[305,576,577],{"x":355,"y":558,"fontSize":334,"fill":312},"SAVEPOINT sa_2",[305,579,580],{"x":355,"y":562,"fontSize":334,"fill":312},"SELECT count(*) → 1",[305,582,583],{"x":355,"y":566,"fontSize":334,"fill":376},"RELEASE SAVEPOINT sa_2",[305,585,586],{"x":355,"y":570,"fontSize":334,"fill":312},"← the row is visible here",[297,588],{"x":589,"y":590,"width":533,"height":591,"rx":592,"fill":321,"stroke":322,"strokeWidth":323},"200","208","30","9",[305,594,596],{"x":533,"y":595,"textAnchor":309,"fontSize":548,"fontWeight":311,"fill":312},"228","ROLLBACK — every row above disappears",[379,598,599,600,602],{},"A bare ",[14,601,500],{}," anywhere in this log is the leak. Grepping the test run's SQL log for it is a thirty-second audit of the whole suite.",[10,604,605,606,608],{},"That grep generalises into a permanent guard. A small event listener that fails the test if a real ",[14,607,500],{}," reaches the database — outside the handful of tests marked as needing committed data — catches every future regression of the binding at the moment it is introduced, rather than as a mysterious order dependence weeks later:",[61,610,612],{"className":63,"code":611,"language":65,"meta":66,"style":66},"from sqlalchemy import event\n\n\n@pytest.fixture(autouse=True)\ndef forbid_real_commits(request, engine):\n    if request.node.get_closest_marker(\"committed_data\"):\n        yield\n        return\n\n    def on_commit(conn):\n        raise AssertionError(\"a real COMMIT escaped the transactional fixture\")\n\n    event.listen(engine, \"commit\", on_commit)\n    yield\n    event.remove(engine, \"commit\", on_commit)\n",[14,613,614,619,623,627,632,637,642,647,652,656,661,666,670,675,680],{"__ignoreMap":66},[70,615,616],{"class":72,"line":73},[70,617,618],{},"from sqlalchemy import event\n",[70,620,621],{"class":72,"line":79},[70,622,95],{"emptyLinePlaceholder":94},[70,624,625],{"class":72,"line":85},[70,626,95],{"emptyLinePlaceholder":94},[70,628,629],{"class":72,"line":91},[70,630,631],{},"@pytest.fixture(autouse=True)\n",[70,633,634],{"class":72,"line":98},[70,635,636],{},"def forbid_real_commits(request, engine):\n",[70,638,639],{"class":72,"line":103},[70,640,641],{},"    if request.node.get_closest_marker(\"committed_data\"):\n",[70,643,644],{"class":72,"line":109},[70,645,646],{},"        yield\n",[70,648,649],{"class":72,"line":115},[70,650,651],{},"        return\n",[70,653,654],{"class":72,"line":121},[70,655,95],{"emptyLinePlaceholder":94},[70,657,658],{"class":72,"line":127},[70,659,660],{},"    def on_commit(conn):\n",[70,662,663],{"class":72,"line":133},[70,664,665],{},"        raise AssertionError(\"a real COMMIT escaped the transactional fixture\")\n",[70,667,668],{"class":72,"line":138},[70,669,95],{"emptyLinePlaceholder":94},[70,671,672],{"class":72,"line":143},[70,673,674],{},"    event.listen(engine, \"commit\", on_commit)\n",[70,676,677],{"class":72,"line":149},[70,678,679],{},"    yield\n",[70,681,682],{"class":72,"line":155},[70,683,684],{},"    event.remove(engine, \"commit\", on_commit)\n",[10,686,687,688,691,692,695],{},"The listener fires on the engine's ",[14,689,690],{},"commit"," event, which a savepoint release does not trigger, so it is silent for correctly routed sessions and loud for anything that bypassed them. Pairing it with an explicit ",[14,693,694],{},"committed_data"," marker for the tests that genuinely need durable rows keeps the exception visible in the code rather than scattered through fixtures.",[19,697,699],{"id":698},"the-async-variant","The async variant",[10,701,702],{},"The async engine follows the same structure with awaits in the obvious places, plus one extra constraint: the connection belongs to a loop.",[61,704,706],{"className":63,"code":705,"language":65,"meta":66,"style":66},"import pytest_asyncio\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\n\n\n@pytest_asyncio.fixture(scope=\"session\", loop_scope=\"session\")\nasync def async_engine(postgres_dsn):\n    engine = create_async_engine(postgres_dsn.replace(\"postgresql:\u002F\u002F\", \"postgresql+asyncpg:\u002F\u002F\"))\n    yield engine\n    await engine.dispose()\n\n\n@pytest_asyncio.fixture(loop_scope=\"session\")\nasync def async_session(async_engine):\n    async with async_engine.connect() as connection:\n        outer = await connection.begin()\n        session = AsyncSession(bind=connection, join_transaction_mode=\"create_savepoint\")\n        try:\n            yield session\n        finally:\n            await session.close()\n            await outer.rollback()\n",[14,707,708,713,718,722,726,731,736,741,745,750,754,758,763,768,773,778,783,788,793,798,803],{"__ignoreMap":66},[70,709,710],{"class":72,"line":73},[70,711,712],{},"import pytest_asyncio\n",[70,714,715],{"class":72,"line":79},[70,716,717],{},"from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\n",[70,719,720],{"class":72,"line":85},[70,721,95],{"emptyLinePlaceholder":94},[70,723,724],{"class":72,"line":91},[70,725,95],{"emptyLinePlaceholder":94},[70,727,728],{"class":72,"line":98},[70,729,730],{},"@pytest_asyncio.fixture(scope=\"session\", loop_scope=\"session\")\n",[70,732,733],{"class":72,"line":103},[70,734,735],{},"async def async_engine(postgres_dsn):\n",[70,737,738],{"class":72,"line":109},[70,739,740],{},"    engine = create_async_engine(postgres_dsn.replace(\"postgresql:\u002F\u002F\", \"postgresql+asyncpg:\u002F\u002F\"))\n",[70,742,743],{"class":72,"line":115},[70,744,124],{},[70,746,747],{"class":72,"line":121},[70,748,749],{},"    await engine.dispose()\n",[70,751,752],{"class":72,"line":127},[70,753,95],{"emptyLinePlaceholder":94},[70,755,756],{"class":72,"line":133},[70,757,95],{"emptyLinePlaceholder":94},[70,759,760],{"class":72,"line":138},[70,761,762],{},"@pytest_asyncio.fixture(loop_scope=\"session\")\n",[70,764,765],{"class":72,"line":143},[70,766,767],{},"async def async_session(async_engine):\n",[70,769,770],{"class":72,"line":149},[70,771,772],{},"    async with async_engine.connect() as connection:\n",[70,774,775],{"class":72,"line":155},[70,776,777],{},"        outer = await connection.begin()\n",[70,779,780],{"class":72,"line":161},[70,781,782],{},"        session = AsyncSession(bind=connection, join_transaction_mode=\"create_savepoint\")\n",[70,784,785],{"class":72,"line":167},[70,786,787],{},"        try:\n",[70,789,790],{"class":72,"line":173},[70,791,792],{},"            yield session\n",[70,794,795],{"class":72,"line":178},[70,796,797],{},"        finally:\n",[70,799,800],{"class":72,"line":184},[70,801,802],{},"            await session.close()\n",[70,804,805],{"class":72,"line":190},[70,806,807],{},"            await outer.rollback()\n",[10,809,810,813,814,55],{},[14,811,812],{},"loop_scope=\"session\""," on both fixtures is not decoration. The engine's pool holds connections registered with the session loop; a per-test fixture running on a function loop would try to use them from a different loop and fail with the errors catalogued in ",[51,815,817],{"href":816},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Fsharing-an-event-loop-across-a-test-module\u002F","sharing an event loop across a test module",[19,819,821],{"id":820},"pointing-the-application-at-the-fixtures-session","Pointing the application at the fixture's session",[10,823,824],{},"Most real applications do not receive a session as a function argument; they get one from a factory, a dependency-injection container or a framework hook. The fixture has to reach that mechanism, or the application silently writes outside the transaction.",[61,826,828],{"className":63,"code":827,"language":65,"meta":66,"style":66},"import pytest\n\nfrom myapp import db\n\n\n@pytest.fixture(autouse=True)\ndef route_app_sessions(db_session, monkeypatch):\n    # The application calls db.get_session(); in tests it gets the fixture's.\n    monkeypatch.setattr(db, \"get_session\", lambda: db_session)\n",[14,829,830,834,838,843,847,851,855,860,865],{"__ignoreMap":66},[70,831,832],{"class":72,"line":73},[70,833,76],{},[70,835,836],{"class":72,"line":79},[70,837,95],{"emptyLinePlaceholder":94},[70,839,840],{"class":72,"line":85},[70,841,842],{},"from myapp import db\n",[70,844,845],{"class":72,"line":91},[70,846,95],{"emptyLinePlaceholder":94},[70,848,849],{"class":72,"line":98},[70,850,95],{"emptyLinePlaceholder":94},[70,852,853],{"class":72,"line":103},[70,854,631],{},[70,856,857],{"class":72,"line":109},[70,858,859],{},"def route_app_sessions(db_session, monkeypatch):\n",[70,861,862],{"class":72,"line":115},[70,863,864],{},"    # The application calls db.get_session(); in tests it gets the fixture's.\n",[70,866,867],{"class":72,"line":121},[70,868,869],{},"    monkeypatch.setattr(db, \"get_session\", lambda: db_session)\n",[61,871,873],{"className":63,"code":872,"language":65,"meta":66,"style":66},"# FastAPI: override the dependency rather than patching a module.\nimport pytest\nfrom fastapi.testclient import TestClient\n\nfrom myapp.main import app, get_db\n\n\n@pytest.fixture\ndef client(db_session):\n    app.dependency_overrides[get_db] = lambda: db_session\n    try:\n        yield TestClient(app)\n    finally:\n        app.dependency_overrides.clear()\n",[14,874,875,880,884,889,893,898,902,906,910,915,920,924,929,933],{"__ignoreMap":66},[70,876,877],{"class":72,"line":73},[70,878,879],{},"# FastAPI: override the dependency rather than patching a module.\n",[70,881,882],{"class":72,"line":79},[70,883,76],{},[70,885,886],{"class":72,"line":85},[70,887,888],{},"from fastapi.testclient import TestClient\n",[70,890,891],{"class":72,"line":91},[70,892,95],{"emptyLinePlaceholder":94},[70,894,895],{"class":72,"line":98},[70,896,897],{},"from myapp.main import app, get_db\n",[70,899,900],{"class":72,"line":103},[70,901,95],{"emptyLinePlaceholder":94},[70,903,904],{"class":72,"line":109},[70,905,95],{"emptyLinePlaceholder":94},[70,907,908],{"class":72,"line":115},[70,909,146],{},[70,911,912],{"class":72,"line":121},[70,913,914],{},"def client(db_session):\n",[70,916,917],{"class":72,"line":127},[70,918,919],{},"    app.dependency_overrides[get_db] = lambda: db_session\n",[70,921,922],{"class":72,"line":133},[70,923,199],{},[70,925,926],{"class":72,"line":138},[70,927,928],{},"        yield TestClient(app)\n",[70,930,931],{"class":72,"line":143},[70,932,211],{},[70,934,935],{"class":72,"line":149},[70,936,937],{},"        app.dependency_overrides.clear()\n",[10,939,940,941,55],{},"Both routes converge on the same property: every query the application makes goes through the one connection holding the outer transaction. The FastAPI version is preferable where it applies, because dependency overrides are a supported extension point rather than a patch — the argument made generally in ",[51,942,944],{"href":943},"\u002Fadvanced-mocking-test-doubles-in-python\u002Fdependency-injection-for-testability\u002F","dependency injection for testability",[274,946,948,1042],{"className":947},[277],[279,949,287,954,287,957,287,960,287,976,287,979,287,982,287,989,287,992,287,995,287,1001,287,1004,287,1008,287,1011,287,1015,287,1021,287,1026,287,1029,287,1033,287,1038],{"viewBox":950,"role":282,"ariaLabelledBy":951,"xmlns":286},"0 0 800 226",[952,953],"route-t","route-d",[289,955,956],{"id":952},"Routing every application query through one connection",[293,958,959],{"id":953},"The fixture opens one connection and an outer transaction. A dependency override or a patched session factory hands that session to the application's request handler and service layer, so all their queries share the connection and are undone by the single rollback at teardown.",[961,962,963,964,287],"defs",{},"\n    ",[965,966,972],"marker",{"id":967,"viewBox":968,"refX":592,"refY":969,"markerWidth":970,"markerHeight":970,"orient":971},"route-a","0 0 10 10","5","7","auto-start-reverse",[973,974],"path",{"d":975,"fill":357},"M0 0 L10 5 L0 10 z",[297,977],{"x":299,"y":299,"width":529,"height":978,"rx":302,"fill":303},"226",[305,980,981],{"x":533,"y":308,"textAnchor":309,"fontSize":534,"fontWeight":311,"fill":312},"One connection, every query, one rollback",[297,983],{"x":538,"y":984,"width":985,"height":986,"rx":334,"fill":987,"stroke":988,"strokeWidth":323},"70","220","80","#f7f0da","#f2cc8f",[305,990,991],{"x":566,"y":558,"textAnchor":309,"fontSize":320,"fontWeight":311,"fill":312},"db_session fixture",[305,993,994],{"x":566,"y":562,"textAnchor":309,"fontSize":334,"fill":312},"connection A, BEGIN",[72,996],{"x1":997,"y1":998,"x2":999,"y2":998,"stroke":357,"strokeWidth":543,"markerEnd":1000},"258","110","306","url(#route-a)",[297,1002],{"x":1003,"y":984,"width":589,"height":986,"rx":334,"fill":356,"stroke":357,"strokeWidth":323},"312",[305,1005,1007],{"x":1006,"y":558,"textAnchor":309,"fontSize":320,"fontWeight":311,"fill":312},"412","dependency override",[305,1009,1010],{"x":1006,"y":562,"textAnchor":309,"fontSize":334,"fill":312},"get_db → db_session",[72,1012],{"x1":1013,"y1":998,"x2":1014,"y2":998,"stroke":357,"strokeWidth":543,"markerEnd":1000},"516","564",[297,1016],{"x":1017,"y":1018,"width":589,"height":332,"rx":554,"fill":303,"stroke":1019,"strokeWidth":1020},"570","58","rgba(61,64,91,0.35)","1.5",[305,1022,1025],{"x":1023,"y":1024,"textAnchor":309,"fontSize":334,"fill":312},"670","85","request handler",[297,1027],{"x":1017,"y":1028,"width":589,"height":332,"rx":554,"fill":303,"stroke":1019,"strokeWidth":1020},"112",[305,1030,1032],{"x":1023,"y":1031,"textAnchor":309,"fontSize":334,"fill":312},"139","service layer",[297,1034],{"x":538,"y":1035,"width":1036,"height":1037,"rx":592,"fill":303,"stroke":1019,"strokeWidth":1020},"176","736","36",[305,1039,1041],{"x":533,"y":1040,"textAnchor":309,"fontSize":548,"fill":312},"199","Anything that opens its own Session(engine) falls outside this picture and leaks.",[379,1043,1044],{},"The pair of order-independent tests above is the check that this routing is complete. If either fails, something in the application found another way to the pool.",[19,1046,1048],{"id":1047},"cost-measured","Cost, measured",[10,1050,1051,1052,1054],{},"The reason to go to this trouble is speed, and it is worth knowing the size of the effect. On a typical Postgres container, a test that truncates six tables in teardown spends thirty to sixty milliseconds on cleanup alone; the same test with this fixture spends well under one millisecond on its ",[14,1053,507],{},". Across four hundred database tests that is the difference between roughly twenty seconds of pure cleanup and effectively none, before counting the setup savings from never recreating anything.",[10,1056,1057],{},"The less visible benefit is reliability. A truncation fixture that raises halfway — a foreign key it did not know about, a table added last week — leaves the database dirty and fails every subsequent test with an error unrelated to its cause. A rollback cannot half-fail in that way: either the transaction is rolled back, or the connection is dead and the next test gets a fresh one from the pool. That asymmetry is why the pattern survives in large suites long after the performance argument has been forgotten. It also means the fixture needs no maintenance when the schema grows: a new table is covered automatically, where a cleanup routine would need editing.",[19,1059,1061],{"id":1060},"frequently-asked-questions","Frequently Asked Questions",[10,1063,1064,1067],{},[417,1065,1066],{},"Why do rows survive between tests even though the fixture rolls back?","\nUsually because the session is bound to the engine rather than to the connection that owns the outer transaction, so it checks out a different connection from the pool and commits there. Bind the session to the specific connection on which the outer transaction was begun.",[10,1069,1070,1073,1074,1077,1078,1081,1082,1084],{},[417,1071,1072],{},"Does this work with the async SQLAlchemy engine?","\nYes. ",[14,1075,1076],{},"AsyncConnection.begin"," and ",[14,1079,1080],{},"AsyncSession"," with ",[14,1083,399],{}," behave identically; the fixture becomes an async generator and the rollback is awaited. Match the fixture's loop scope to the engine's so the connection and its loop share a lifetime.",[10,1086,1087,1090],{},[417,1088,1089],{},"What about code that opens its own session?","\nIt bypasses the fixture entirely and writes outside the rolled-back transaction. Either inject the session so the code uses the fixture's, or configure the application's session factory in tests to bind to the fixture's connection.",[19,1092,1094],{"id":1093},"related","Related",[24,1096,1097,1103,1110,1121],{},[27,1098,1099,1102],{},[51,1100,1101],{"href":53},"Database Fixtures & Transactional Tests"," — the wider layering this fixture sits in.",[27,1104,1105,1109],{},[51,1106,1108],{"href":1107},"\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Ftesting-alembic-migrations-in-ci\u002F","Testing Alembic Migrations in CI"," — building the database this fixture connects to.",[27,1111,1112,1116,1117,1120],{},[51,1113,1115],{"href":1114},"\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Fstarting-postgres-with-testcontainers-python\u002F","Starting Postgres with testcontainers-python"," — where ",[14,1118,1119],{},"postgres_dsn"," comes from.",[27,1122,1123,1127],{},[51,1124,1126],{"href":1125},"\u002Fintegration-database-and-service-testing\u002Ftest-data-factories-and-builders\u002Ffactory-boy-versus-plain-fixture-builders\u002F","factory_boy versus Plain Fixture Builders"," — writing rows through this session without escaping it.",[10,1129,1130,1131],{},"← Back to ",[51,1132,1101],{"href":53},[1134,1135,1136],"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);}",{"title":66,"searchDepth":79,"depth":79,"links":1138},[1139,1140,1141,1142,1143,1144,1145,1146,1147,1148],{"id":21,"depth":79,"text":22},{"id":58,"depth":79,"text":59},{"id":388,"depth":79,"text":389},{"id":410,"depth":79,"text":411},{"id":464,"depth":79,"text":465},{"id":698,"depth":79,"text":699},{"id":820,"depth":79,"text":821},{"id":1047,"depth":79,"text":1048},{"id":1060,"depth":79,"text":1061},{"id":1093,"depth":79,"text":1094},"Build a SQLAlchemy 2.0 fixture that rolls back after every test even when the code commits: bound connections, create_savepoint, async engines and verification.","md",{"slug":1152,"type":1153,"breadcrumb":1154,"datePublished":1155,"dateModified":1155,"faq":1156,"howto":1163},"rolling-back-every-test-with-nested-transactions","article","Nested Rollback","2026-09-18",[1157,1159,1161],{"q":1066,"a":1158},"Usually because the session is bound to the engine rather than to the connection that owns the outer transaction, so it checks out a different connection from the pool and commits there. Bind the session to the specific connection on which the outer transaction was begun.",{"q":1072,"a":1160},"Yes. AsyncConnection.begin and AsyncSession with join_transaction_mode='create_savepoint' behave identically; the fixture becomes an async generator and the rollback is awaited. Match the fixture's loop scope to the engine's so the connection and its loop share a lifetime.",{"q":1089,"a":1162},"It bypasses the fixture entirely and writes outside the rolled-back transaction. Either inject the session so the code uses the fixture's, or configure the application's session factory in tests to bind to the fixture's connection.",{"name":1164,"description":1165,"steps":1166},"How to roll back every test with nested transactions","Open an outer transaction on a dedicated connection, run the ORM session inside a savepoint, and roll back in a finally.",[1167,1170,1173,1176,1179],{"name":1168,"text":1169},"Create the engine once per session","Build a single engine and pool at session scope, pointed at a database built by running migrations.",{"name":1171,"text":1172},"Begin an outer transaction on one connection","Check out a connection, begin a transaction on it, and keep a handle to both.",{"name":1174,"text":1175},"Bind the session to that connection with savepoints","Create the Session bound to the connection with join_transaction_mode set to create_savepoint.",{"name":1177,"text":1178},"Roll back in finally","Close the session and roll back the outer transaction whether the test passed, failed or raised.",{"name":1180,"text":1181},"Prove it with a pair of order-independent tests","One test commits a row, the other asserts the table is empty, and both pass in either order.","\u002Fintegration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Frolling-back-every-test-with-nested-transactions",{"title":5,"description":1149},"integration-database-and-service-testing\u002Fdatabase-fixtures-and-transactional-tests\u002Frolling-back-every-test-with-nested-transactions\u002Findex","K7AqoT30eu68-qQ7W985nCaKZ_gv2t-YQjgiqV9gE-s",1789718767476]