[{"data":1,"prerenderedAt":1804},["ShallowReactive",2],{"page-\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002F":3},{"id":4,"title":5,"body":6,"description":1764,"extension":1765,"meta":1766,"navigation":369,"path":1800,"seo":1801,"stem":1802,"__hash__":1803},"content\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Findex.md","pytest-asyncio in Depth",{"type":7,"value":8,"toc":1745},"minimark",[9,26,31,86,90,101,110,247,281,285,290,337,344,348,433,437,509,513,560,567,571,613,626,630,633,694,701,716,720,848,852,855,872,884,895,1033,1037,1040,1240,1252,1259,1263,1272,1278,1332,1379,1388,1453,1466,1479,1483,1493,1496,1525,1531,1538,1541,1596,1603,1607,1628,1648,1671,1687,1693,1697,1734,1741],[10,11,12,13,17,18,21,22,25],"p",{},"A suite passes locally and fails in CI with ",[14,15,16],"code",{},"RuntimeError: Event loop is closed"," on the eleventh test, or with ",[14,19,20],{},"attached to a different loop",", or it simply hangs until the job times out. In every case the cause is the same: an object was created on one event loop and used on another. ",[14,23,24],{},"pytest-asyncio"," is the layer that decides which loop is running when, so configuring it correctly is not a formality — it is the difference between a suite that scales past a hundred async tests and one that does not.",[27,28,30],"h2",{"id":29},"prerequisites","Prerequisites",[32,33,34,49,67,75],"ul",{},[35,36,37,40,41,44,45,48],"li",{},[14,38,39],{},"pytest >= 8.0"," and ",[14,42,43],{},"pytest-asyncio >= 0.24",", which is the first release carrying ",[14,46,47],{},"loop_scope"," on both the marker and the fixture decorator.",[35,50,51,52,55,56,59,60,40,63,66],{},"Python 3.10+ for ",[14,53,54],{},"asyncio.timeout"," and the loop-free construction of ",[14,57,58],{},"asyncio.Lock",", ",[14,61,62],{},"Event",[14,64,65],{},"Queue",".",[35,68,69,70,66],{},"Familiarity with pytest fixture scopes; the synchronous rules are covered in ",[71,72,74],"a",{"href":73},"\u002Fadvanced-pytest-architecture-configuration\u002Fmastering-pytest-fixtures\u002F","mastering pytest fixtures",[35,76,77,78,81,82,85],{},"If you are still on ",[14,79,80],{},"pytest-asyncio \u003C 1.0"," with a custom ",[14,83,84],{},"event_loop"," fixture, expect to delete it — the override was deprecated in 0.23 and removed in 1.0.",[27,87,89],{"id":88},"core-concept-two-scopes-not-one","Core concept: two scopes, not one",[10,91,92,93,95,96,100],{},"The single idea that makes ",[14,94,24],{}," predictable is that a fixture has ",[97,98,99],"em",{},"two"," independent lifetimes.",[10,102,103,106,107,109],{},[14,104,105],{},"scope"," is the familiar pytest one: how often the fixture body executes. ",[14,108,47],{}," is new and orthogonal: which event loop that body runs on. They default to the same value, which is why the distinction goes unnoticed until a suite needs a session-lived connection pool with per-test state.",[111,112,115,237],"figure",{"className":113},[114],"diagram",[116,117,124,125,124,129,124,133,124,141,124,151,124,157,124,160,124,164,124,168,124,172,124,182,124,186,124,191,124,196,124,199,124,202,124,205,124,208,124,213,124,217,124,221,124,226,124,228,124,231,124,234],"svg",{"viewBox":118,"role":119,"ariaLabelledBy":120,"xmlns":123},"0 0 840 300","img",[121,122],"scopes-t","scopes-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[126,127,128],"title",{"id":121},"Fixture scope and loop scope as independent axes",[130,131,132],"desc",{"id":122},"A two-by-two grid. Function scope with a function loop gives full isolation at the highest setup cost. Function scope with a session loop gives per-test state on a shared loop, the usual choice for a connection acquired from a pool. Session scope with a session loop suits pools and servers. Session scope with a function loop is the broken combination that produces event loop is closed errors.",[134,135],"rect",{"x":136,"y":136,"width":137,"height":138,"rx":139,"fill":140},"0","840","300","14","#fffdf8",[142,143,150],"text",{"x":144,"y":145,"textAnchor":146,"fontSize":147,"fontWeight":148,"fill":149},"420","28","middle","16","700","#3d405b","scope decides how often; loop_scope decides where",[142,152,156],{"x":153,"y":154,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"112","70","12","loop_scope →",[142,158,159],{"x":138,"y":154,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"function loop",[142,161,163],{"x":162,"y":154,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"600","session loop",[142,165,167],{"x":153,"y":166,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"130","scope = function",[142,169,171],{"x":153,"y":170,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"230","scope = session",[134,173],{"x":174,"y":175,"width":176,"height":177,"rx":178,"fill":179,"stroke":180,"strokeWidth":181},"190","88","220","86","10","#e6f0ea","#81b29a","2",[142,183,185],{"x":138,"y":184,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"114","full isolation",[142,187,190],{"x":138,"y":188,"textAnchor":146,"fontSize":189,"fill":149},"134","11","the safe default",[142,192,195],{"x":138,"y":193,"textAnchor":146,"fontSize":189,"fill":194},"154","#2a5f49","costs setup per test",[134,197],{"x":198,"y":175,"width":176,"height":177,"rx":178,"fill":179,"stroke":180,"strokeWidth":181},"490",[142,200,201],{"x":162,"y":184,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"per-test state,",[142,203,204],{"x":162,"y":188,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"shared loop",[142,206,207],{"x":162,"y":193,"textAnchor":146,"fontSize":189,"fill":194},"a connection from a pool",[134,209],{"x":174,"y":210,"width":176,"height":177,"rx":178,"fill":211,"stroke":212,"strokeWidth":181},"188","#fbe9e3","#e07a5f",[142,214,216],{"x":138,"y":215,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"214","broken",[142,218,220],{"x":138,"y":219,"textAnchor":146,"fontSize":189,"fill":149},"234","resource outlives its loop",[142,222,225],{"x":138,"y":223,"textAnchor":146,"fontSize":189,"fill":224},"254","#8f3d22","Event loop is closed",[134,227],{"x":198,"y":210,"width":176,"height":177,"rx":178,"fill":179,"stroke":180,"strokeWidth":181},[142,229,230],{"x":162,"y":215,"textAnchor":146,"fontSize":155,"fontWeight":148,"fill":149},"pools and servers",[142,232,233],{"x":162,"y":219,"textAnchor":146,"fontSize":189,"fill":149},"one handshake per run",[142,235,236],{"x":162,"y":223,"textAnchor":146,"fontSize":189,"fill":194},"teardown still on the loop",[238,239,240,241,243,244,246],"figcaption",{},"Only one cell is wrong, and it is the one teams reach for first: widening ",[14,242,105],{}," to make a fixture faster without widening ",[14,245,47],{}," to match.",[10,248,249,250,253,254,257,258,261,262,265,266,269,270,272,273,276,277,280],{},"The second idea is that ",[14,251,252],{},"asyncio_mode"," is about ",[97,255,256],{},"collection",", not execution. In ",[14,259,260],{},"strict"," mode a coroutine test runs only when it carries ",[14,263,264],{},"@pytest.mark.asyncio","; in ",[14,267,268],{},"auto"," mode every coroutine test function and async fixture is claimed automatically. ",[14,271,268],{}," is right for a suite that is async throughout, and actively wrong for a repository that also uses ",[14,274,275],{},"anyio",", because both plugins implement ",[14,278,279],{},"pytest_pyfunc_call"," and only the first to claim a test wins.",[27,282,284],{"id":283},"step-by-step-implementation","Step-by-step implementation",[286,287,289],"h3",{"id":288},"_1-configure-the-plugin-once-explicitly","1. Configure the plugin once, explicitly",[291,292,297],"pre",{"className":293,"code":294,"language":295,"meta":296,"style":296},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.pytest.ini_options]\nasyncio_mode = \"auto\"\n# Explicit rather than implicit: without this, 0.24+ emits a deprecation warning\n# and a future release changes the default under you.\nasyncio_default_fixture_loop_scope = \"function\"\n","toml","",[14,298,299,307,313,319,325,331],{"__ignoreMap":296},[300,301,304],"span",{"class":302,"line":303},"line",1,[300,305,306],{},"# pyproject.toml\n",[300,308,310],{"class":302,"line":309},2,[300,311,312],{},"[tool.pytest.ini_options]\n",[300,314,316],{"class":302,"line":315},3,[300,317,318],{},"asyncio_mode = \"auto\"\n",[300,320,322],{"class":302,"line":321},4,[300,323,324],{},"# Explicit rather than implicit: without this, 0.24+ emits a deprecation warning\n",[300,326,328],{"class":302,"line":327},5,[300,329,330],{},"# and a future release changes the default under you.\n",[300,332,334],{"class":302,"line":333},6,[300,335,336],{},"asyncio_default_fixture_loop_scope = \"function\"\n",[10,338,339,340,343],{},"Setting ",[14,341,342],{},"asyncio_default_fixture_loop_scope"," is not optional housekeeping. Leaving it unset means the plugin picks a default that has already changed once, so a routine dependency bump can silently move every fixture onto a different loop. Pinning it makes the upgrade a deliberate change with a visible diff.",[286,345,347],{"id":346},"_2-put-expensive-resources-on-a-wide-loop","2. Put expensive resources on a wide loop",[291,349,353],{"className":350,"code":351,"language":352,"meta":296,"style":296},"language-python shiki shiki-themes github-light github-dark","import asyncpg\nimport pytest_asyncio\n\n\n@pytest_asyncio.fixture(scope=\"session\", loop_scope=\"session\")\nasync def pool():\n    # Both scopes agree: the pool's sockets are registered with the session loop\n    # and closed while that loop is still alive.\n    pool = await asyncpg.create_pool(dsn=\"postgresql:\u002F\u002Ftest@localhost\u002Ftest\",\n                                     min_size=2, max_size=10)\n    try:\n        yield pool\n    finally:\n        await pool.close()\n","python",[14,354,355,360,365,371,375,380,385,391,397,403,409,415,421,427],{"__ignoreMap":296},[300,356,357],{"class":302,"line":303},[300,358,359],{},"import asyncpg\n",[300,361,362],{"class":302,"line":309},[300,363,364],{},"import pytest_asyncio\n",[300,366,367],{"class":302,"line":315},[300,368,370],{"emptyLinePlaceholder":369},true,"\n",[300,372,373],{"class":302,"line":321},[300,374,370],{"emptyLinePlaceholder":369},[300,376,377],{"class":302,"line":327},[300,378,379],{},"@pytest_asyncio.fixture(scope=\"session\", loop_scope=\"session\")\n",[300,381,382],{"class":302,"line":333},[300,383,384],{},"async def pool():\n",[300,386,388],{"class":302,"line":387},7,[300,389,390],{},"    # Both scopes agree: the pool's sockets are registered with the session loop\n",[300,392,394],{"class":302,"line":393},8,[300,395,396],{},"    # and closed while that loop is still alive.\n",[300,398,400],{"class":302,"line":399},9,[300,401,402],{},"    pool = await asyncpg.create_pool(dsn=\"postgresql:\u002F\u002Ftest@localhost\u002Ftest\",\n",[300,404,406],{"class":302,"line":405},10,[300,407,408],{},"                                     min_size=2, max_size=10)\n",[300,410,412],{"class":302,"line":411},11,[300,413,414],{},"    try:\n",[300,416,418],{"class":302,"line":417},12,[300,419,420],{},"        yield pool\n",[300,422,424],{"class":302,"line":423},13,[300,425,426],{},"    finally:\n",[300,428,430],{"class":302,"line":429},14,[300,431,432],{},"        await pool.close()\n",[286,434,436],{"id":435},"_3-keep-per-test-state-narrow-on-the-same-loop","3. Keep per-test state narrow, on the same loop",[291,438,440],{"className":350,"code":439,"language":352,"meta":296,"style":296},"import pytest_asyncio\n\n\n@pytest_asyncio.fixture(loop_scope=\"session\")   # scope defaults to \"function\"\nasync def conn(pool):\n    # New connection per test, session loop underneath: isolation without\n    # re-handshaking, and nothing crosses a loop boundary.\n    async with pool.acquire() as connection:\n        transaction = connection.transaction()\n        await transaction.start()\n        try:\n            yield connection\n        finally:\n            await transaction.rollback()\n",[14,441,442,446,450,454,459,464,469,474,479,484,489,494,499,504],{"__ignoreMap":296},[300,443,444],{"class":302,"line":303},[300,445,364],{},[300,447,448],{"class":302,"line":309},[300,449,370],{"emptyLinePlaceholder":369},[300,451,452],{"class":302,"line":315},[300,453,370],{"emptyLinePlaceholder":369},[300,455,456],{"class":302,"line":321},[300,457,458],{},"@pytest_asyncio.fixture(loop_scope=\"session\")   # scope defaults to \"function\"\n",[300,460,461],{"class":302,"line":327},[300,462,463],{},"async def conn(pool):\n",[300,465,466],{"class":302,"line":333},[300,467,468],{},"    # New connection per test, session loop underneath: isolation without\n",[300,470,471],{"class":302,"line":387},[300,472,473],{},"    # re-handshaking, and nothing crosses a loop boundary.\n",[300,475,476],{"class":302,"line":393},[300,477,478],{},"    async with pool.acquire() as connection:\n",[300,480,481],{"class":302,"line":399},[300,482,483],{},"        transaction = connection.transaction()\n",[300,485,486],{"class":302,"line":405},[300,487,488],{},"        await transaction.start()\n",[300,490,491],{"class":302,"line":411},[300,492,493],{},"        try:\n",[300,495,496],{"class":302,"line":417},[300,497,498],{},"            yield connection\n",[300,500,501],{"class":302,"line":423},[300,502,503],{},"        finally:\n",[300,505,506],{"class":302,"line":429},[300,507,508],{},"            await transaction.rollback()\n",[286,510,512],{"id":511},"_4-mark-the-tests-that-need-the-wide-loop","4. Mark the tests that need the wide loop",[291,514,516],{"className":350,"code":515,"language":352,"meta":296,"style":296},"import pytest\n\n# Module-level: every test here runs on the session loop, so it may use `conn`.\npytestmark = pytest.mark.asyncio(loop_scope=\"session\")\n\n\nasync def test_insert_is_rolled_back(conn):\n    await conn.execute(\"INSERT INTO widget (name) VALUES ($1)\", \"test\")\n    assert await conn.fetchval(\"SELECT count(*) FROM widget\") == 1\n",[14,517,518,523,527,532,537,541,545,550,555],{"__ignoreMap":296},[300,519,520],{"class":302,"line":303},[300,521,522],{},"import pytest\n",[300,524,525],{"class":302,"line":309},[300,526,370],{"emptyLinePlaceholder":369},[300,528,529],{"class":302,"line":315},[300,530,531],{},"# Module-level: every test here runs on the session loop, so it may use `conn`.\n",[300,533,534],{"class":302,"line":321},[300,535,536],{},"pytestmark = pytest.mark.asyncio(loop_scope=\"session\")\n",[300,538,539],{"class":302,"line":327},[300,540,370],{"emptyLinePlaceholder":369},[300,542,543],{"class":302,"line":333},[300,544,370],{"emptyLinePlaceholder":369},[300,546,547],{"class":302,"line":387},[300,548,549],{},"async def test_insert_is_rolled_back(conn):\n",[300,551,552],{"class":302,"line":393},[300,553,554],{},"    await conn.execute(\"INSERT INTO widget (name) VALUES ($1)\", \"test\")\n",[300,556,557],{"class":302,"line":399},[300,558,559],{},"    assert await conn.fetchval(\"SELECT count(*) FROM widget\") == 1\n",[10,561,562,563,566],{},"A test on a ",[97,564,565],{},"narrower"," loop than a fixture it requests is the error pytest-asyncio reports as a fixture being \"requested from a different event loop\". The rule is one-directional: a test may use fixtures on its own loop or on any wider one, never on a narrower one.",[286,568,570],{"id":569},"_5-swap-the-loop-implementation-through-the-policy","5. Swap the loop implementation through the policy",[291,572,574],{"className":350,"code":573,"language":352,"meta":296,"style":296},"import pytest\nimport uvloop\n\n\n@pytest.fixture(scope=\"session\")\ndef event_loop_policy():\n    # Every loop created at session scope and below now comes from uvloop.\n    return uvloop.EventLoopPolicy()\n",[14,575,576,580,585,589,593,598,603,608],{"__ignoreMap":296},[300,577,578],{"class":302,"line":303},[300,579,522],{},[300,581,582],{"class":302,"line":309},[300,583,584],{},"import uvloop\n",[300,586,587],{"class":302,"line":315},[300,588,370],{"emptyLinePlaceholder":369},[300,590,591],{"class":302,"line":321},[300,592,370],{"emptyLinePlaceholder":369},[300,594,595],{"class":302,"line":327},[300,596,597],{},"@pytest.fixture(scope=\"session\")\n",[300,599,600],{"class":302,"line":333},[300,601,602],{},"def event_loop_policy():\n",[300,604,605],{"class":302,"line":387},[300,606,607],{},"    # Every loop created at session scope and below now comes from uvloop.\n",[300,609,610],{"class":302,"line":393},[300,611,612],{},"    return uvloop.EventLoopPolicy()\n",[10,614,615,616,618,619,622,623,625],{},"This is the supported replacement for the removed ",[14,617,84],{}," override. It changes how loops are ",[97,620,621],{},"made"," without touching when they live and die, which is why it composes with ",[14,624,47],{}," instead of fighting it.",[27,627,629],{"id":628},"verification","Verification",[10,631,632],{},"Do not infer loop identity — assert it. A temporary test that prints the running loop's id makes the whole configuration visible in one run:",[291,634,636],{"className":350,"code":635,"language":352,"meta":296,"style":296},"import asyncio\n\nimport pytest\n\npytestmark = pytest.mark.asyncio(loop_scope=\"session\")\n\n\nasync def test_loop_identity_a(request):\n    print(f\"{request.node.name}: loop={id(asyncio.get_running_loop()):x}\")\n\n\nasync def test_loop_identity_b(request):\n    print(f\"{request.node.name}: loop={id(asyncio.get_running_loop()):x}\")\n",[14,637,638,643,647,651,655,659,663,667,672,677,681,685,690],{"__ignoreMap":296},[300,639,640],{"class":302,"line":303},[300,641,642],{},"import asyncio\n",[300,644,645],{"class":302,"line":309},[300,646,370],{"emptyLinePlaceholder":369},[300,648,649],{"class":302,"line":315},[300,650,522],{},[300,652,653],{"class":302,"line":321},[300,654,370],{"emptyLinePlaceholder":369},[300,656,657],{"class":302,"line":327},[300,658,536],{},[300,660,661],{"class":302,"line":333},[300,662,370],{"emptyLinePlaceholder":369},[300,664,665],{"class":302,"line":387},[300,666,370],{"emptyLinePlaceholder":369},[300,668,669],{"class":302,"line":393},[300,670,671],{},"async def test_loop_identity_a(request):\n",[300,673,674],{"class":302,"line":399},[300,675,676],{},"    print(f\"{request.node.name}: loop={id(asyncio.get_running_loop()):x}\")\n",[300,678,679],{"class":302,"line":405},[300,680,370],{"emptyLinePlaceholder":369},[300,682,683],{"class":302,"line":411},[300,684,370],{"emptyLinePlaceholder":369},[300,686,687],{"class":302,"line":417},[300,688,689],{},"async def test_loop_identity_b(request):\n",[300,691,692],{"class":302,"line":423},[300,693,676],{},[291,695,699],{"className":696,"code":698,"language":142,"meta":296},[697],"language-text","$ pytest -s -q test_loops.py\ntest_loop_identity_a: loop=7f3c8a1b2e80\ntest_loop_identity_b: loop=7f3c8a1b2e80\n",[14,700,698],{"__ignoreMap":296},[10,702,703,704,707,708,711,712,66],{},"Matching ids confirm the module shares a loop. Run ",[14,705,706],{},"pytest --setup-show"," alongside it to see the fixture order, and ",[14,709,710],{},"-W error::DeprecationWarning"," to surface plugin deprecations before they become breakage — the general technique is in ",[71,713,715],{"href":714},"\u002Fadvanced-pytest-architecture-configuration\u002Fpytest-configuration-best-practices\u002Fturning-warnings-into-errors-with-filterwarnings\u002F","turning warnings into errors with filterwarnings",[27,717,719],{"id":718},"troubleshooting","Troubleshooting",[721,722,723,739],"table",{},[724,725,726],"thead",{},[727,728,729,733,736],"tr",{},[730,731,732],"th",{},"Symptom",[730,734,735],{},"Root cause",[730,737,738],{},"Fix",[740,741,742,760,775,793,806,825],"tbody",{},[727,743,744,750,753],{},[745,746,747,749],"td",{},[14,748,16],{}," on the second test",[745,751,752],{},"Session-scoped resource built on a function loop",[745,754,755,756,759],{},"Add ",[14,757,758],{},"loop_scope=\"session\""," to the fixture",[727,761,762,767,770],{},[745,763,764],{},[14,765,766],{},"got Future attached to a different loop",[745,768,769],{},"Two loops alive; a future created on one awaited on the other",[745,771,772,773],{},"Make every fixture in the chain share one ",[14,774,47],{},[727,776,777,783,786],{},[745,778,779,782],{},[14,780,781],{},"PytestUnhandledCoroutineWarning"," or an error about a non-None return",[745,784,785],{},"The test was never claimed by a plugin",[745,787,788,789,792],{},"Set ",[14,790,791],{},"asyncio_mode = \"auto\""," or add the marker",[727,794,795,798,803],{},[745,796,797],{},"Async test reported as skipped with no reason",[745,799,800,802],{},[14,801,275],{}," claimed it first and its backend fixture is missing",[745,804,805],{},"Isolate backend-parametrised tests in their own directory",[727,807,808,814,817],{},[745,809,810,813],{},[14,811,812],{},"fixture 'event_loop' not found"," after an upgrade",[745,815,816],{},"The override was removed in 1.0",[745,818,819,820,40,822],{},"Delete it; use ",[14,821,47],{},[14,823,824],{},"event_loop_policy",[727,826,827,830,840],{},[745,828,829],{},"Teardown raises but the test passed",[745,831,832,835,836,839],{},[14,833,834],{},"await"," in a ",[14,837,838],{},"finally"," running after the loop closed",[745,841,842,843,845,846],{},"Match the fixture's ",[14,844,47],{}," to its ",[14,847,105],{},[27,849,851],{"id":850},"what-actually-breaks-when-loops-are-mismatched","What actually breaks when loops are mismatched",[10,853,854],{},"It is worth understanding the failure at the level of the selector, because the error messages point at the symptom rather than the cause.",[10,856,857,858,861,862,865,866,868,869,871],{},"An ",[14,859,860],{},"asyncio"," loop owns a selector holding file descriptors registered by whatever ran on it. When the loop closes, those registrations go away, but the Python objects wrapping them do not: a connection object still has its socket, still believes it is usable, and still has a ",[14,863,864],{},"_loop"," attribute pointing at a closed loop. The next ",[14,867,834],{}," on it schedules a callback on that dead loop, and you get ",[14,870,225],{}," — from a line of code that is entirely correct, several tests away from the fixture that actually caused the problem.",[10,873,874,875,878,879,883],{},"Hangs come from the same mechanism one step further along. If the object's loop reference is to a loop that was never closed but is no longer ",[97,876,877],{},"running"," — common when a fixture created a loop manually — the await registers a callback that nothing will ever execute. No exception, no timeout, just a test that never returns. This is precisely why the ",[71,880,882],{"href":881},"\u002Ftesting-async-and-concurrent-python\u002Ftimeouts-cancellation-and-deadlines\u002F","timeout layer"," exists: a hung await with a deadline becomes a failing test with a stack trace, and a hung await without one becomes a cancelled CI job.",[10,885,886,887,889,890,894],{},"The diagnosis path is short once you know the shape. Run the failing test alone; if it passes, the problem is cross-test state. Then run it with the test immediately before it, which finds the pair. ",[14,888,706],{}," prints fixture setup and teardown in execution order with their scopes, and the offending fixture is the one whose scope is wider than the loop it ran on. Where the failure only appears under parallelism, the isolation techniques in ",[71,891,893],{"href":892},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fdebugging-a-test-that-only-fails-under-xdist\u002F","debugging a test that only fails under xdist"," narrow it further.",[111,896,898,1030],{"className":897},[114],[116,899,124,904,124,907,124,910,124,927,124,931,124,935,124,943,124,949,124,955,124,958,124,963,124,966,124,969,124,974,124,978,124,982,124,986,124,989,124,993,124,997,124,1000,124,1004,124,1008,124,1012,124,1015,124,1017,124,1020,124,1022,124,1026],{"viewBox":900,"role":119,"ariaLabelledBy":901,"xmlns":123},"0 0 800 260",[902,903],"mismatch-t","mismatch-d",[126,905,906],{"id":902},"How a scope mismatch becomes an error three tests later",[130,908,909],{"id":903},"A timeline across three tests. During the first test a session-scoped fixture creates a pool on the function-scoped loop. That loop is closed at the end of the first test, but the pool object survives. The second test runs on a new loop, and the first await on the surviving pool raises Event loop is closed, far from the fixture that caused it.",[911,912,913,914,124],"defs",{},"\n    ",[915,916,923],"marker",{"id":917,"viewBox":918,"refX":919,"refY":920,"markerWidth":921,"markerHeight":921,"orient":922},"mismatch-a","0 0 10 10","9","5","7","auto-start-reverse",[924,925],"path",{"d":926,"fill":212},"M0 0 L10 5 L0 10 z",[134,928],{"x":136,"y":136,"width":929,"height":930,"rx":139,"fill":140},"800","260",[142,932,934],{"x":933,"y":145,"textAnchor":146,"fontSize":147,"fontWeight":148,"fill":149},"400","The fixture is at fault; the traceback names the victim",[134,936],{"x":937,"y":938,"width":170,"height":939,"rx":155,"fill":940,"stroke":941,"strokeWidth":942},"34","52","150","#f4f1de","rgba(61,64,91,0.35)","1.5",[142,944,948],{"x":945,"y":946,"textAnchor":146,"fontSize":947,"fontWeight":148,"fill":149},"149","76","12.5","test 1",[134,950],{"x":951,"y":952,"width":174,"height":937,"rx":953,"fill":179,"stroke":180,"strokeWidth":954},"54","90","8","1.8",[142,956,957],{"x":945,"y":153,"textAnchor":146,"fontSize":189,"fill":149},"loop A created",[134,959],{"x":951,"y":960,"width":174,"height":937,"rx":953,"fill":961,"stroke":962,"strokeWidth":954},"132","#f7f0da","#f2cc8f",[142,964,965],{"x":945,"y":193,"textAnchor":146,"fontSize":189,"fill":149},"pool built on loop A",[142,967,968],{"x":945,"y":210,"textAnchor":146,"fontSize":189,"fill":149},"test passes",[302,970],{"x1":971,"y1":972,"x2":138,"y2":972,"stroke":212,"strokeWidth":954,"markerEnd":973},"268","128","url(#mismatch-a)",[134,975],{"x":976,"y":938,"width":977,"height":939,"rx":155,"fill":940,"stroke":941,"strokeWidth":942},"306","180",[142,979,981],{"x":980,"y":946,"textAnchor":146,"fontSize":947,"fontWeight":148,"fill":149},"396","teardown",[134,983],{"x":984,"y":952,"width":985,"height":937,"rx":953,"fill":211,"stroke":212,"strokeWidth":954},"322","148",[142,987,988],{"x":980,"y":153,"textAnchor":146,"fontSize":189,"fill":149},"loop A closed",[142,990,992],{"x":980,"y":991,"textAnchor":146,"fontSize":189,"fill":149},"146","pool object survives",[142,994,996],{"x":980,"y":995,"textAnchor":146,"fontSize":189,"fill":224},"166","holds a dead selector",[302,998],{"x1":198,"y1":972,"x2":999,"y2":972,"stroke":212,"strokeWidth":954,"markerEnd":973},"522",[134,1001],{"x":1002,"y":938,"width":1003,"height":939,"rx":155,"fill":940,"stroke":941,"strokeWidth":942},"528","238",[142,1005,1007],{"x":1006,"y":946,"textAnchor":146,"fontSize":947,"fontWeight":148,"fill":149},"647","test 2",[134,1009],{"x":1010,"y":952,"width":1011,"height":937,"rx":953,"fill":179,"stroke":180,"strokeWidth":954},"548","198",[142,1013,1014],{"x":1006,"y":153,"textAnchor":146,"fontSize":189,"fill":149},"loop B created",[134,1016],{"x":1010,"y":960,"width":1011,"height":937,"rx":953,"fill":211,"stroke":212,"strokeWidth":954},[142,1018,1019],{"x":1006,"y":193,"textAnchor":146,"fontSize":189,"fill":149},"await pool.fetch(...)",[142,1021,16],{"x":1006,"y":210,"textAnchor":146,"fontSize":189,"fontWeight":148,"fill":224},[134,1023],{"x":937,"y":1024,"width":1025,"height":937,"rx":919,"fill":140,"stroke":941,"strokeWidth":942},"216","732",[142,1027,1029],{"x":933,"y":1003,"textAnchor":146,"fontSize":1028,"fill":149},"11.5","Read the scope of the fixture, not the line number of the failure.",[238,1031,1032],{},"The error surfaces at the first await on the orphaned object, which is usually in a different test and a different module from the fixture that built it.",[27,1034,1036],{"id":1035},"fixtures-that-start-a-real-server","Fixtures that start a real server",[10,1038,1039],{},"The most common session-scoped async fixture is not a pool but a server: a FastAPI or aiohttp application bound to an ephemeral port so tests can exercise it over a real socket. It is also the fixture that most often gets the loop wrong, because the server keeps background tasks alive and those tasks hold references to the loop that created them.",[291,1041,1043],{"className":350,"code":1042,"language":352,"meta":296,"style":296},"import asyncio\nimport socket\n\nimport pytest_asyncio\nimport uvicorn\n\nfrom myapp.main import app\n\n\ndef _free_port() -> int:\n    # Bind to port 0 and let the OS choose; hardcoding a port makes parallel\n    # workers collide and produces \"address already in use\" in CI only.\n    with socket.socket() as sock:\n        sock.bind((\"127.0.0.1\", 0))\n        return sock.getsockname()[1]\n\n\n@pytest_asyncio.fixture(scope=\"session\", loop_scope=\"session\")\nasync def live_server():\n    port = _free_port()\n    config = uvicorn.Config(app, host=\"127.0.0.1\", port=port, log_level=\"warning\")\n    server = uvicorn.Server(config)\n    task = asyncio.create_task(server.serve())          # runs on the session loop\n\n    # Wait for readiness by polling the server's own flag, not by sleeping.\n    for _ in range(200):\n        if server.started:\n            break\n        await asyncio.sleep(0.01)\n    else:\n        raise RuntimeError(\"server did not start within 2 seconds\")\n\n    try:\n        yield f\"http:\u002F\u002F127.0.0.1:{port}\"\n    finally:\n        server.should_exit = True\n        await task                                      # teardown on the same loop\n",[14,1044,1045,1049,1054,1058,1062,1067,1071,1076,1080,1084,1089,1094,1099,1104,1109,1115,1120,1125,1130,1136,1142,1148,1154,1160,1165,1171,1177,1183,1189,1195,1201,1207,1212,1217,1223,1228,1234],{"__ignoreMap":296},[300,1046,1047],{"class":302,"line":303},[300,1048,642],{},[300,1050,1051],{"class":302,"line":309},[300,1052,1053],{},"import socket\n",[300,1055,1056],{"class":302,"line":315},[300,1057,370],{"emptyLinePlaceholder":369},[300,1059,1060],{"class":302,"line":321},[300,1061,364],{},[300,1063,1064],{"class":302,"line":327},[300,1065,1066],{},"import uvicorn\n",[300,1068,1069],{"class":302,"line":333},[300,1070,370],{"emptyLinePlaceholder":369},[300,1072,1073],{"class":302,"line":387},[300,1074,1075],{},"from myapp.main import app\n",[300,1077,1078],{"class":302,"line":393},[300,1079,370],{"emptyLinePlaceholder":369},[300,1081,1082],{"class":302,"line":399},[300,1083,370],{"emptyLinePlaceholder":369},[300,1085,1086],{"class":302,"line":405},[300,1087,1088],{},"def _free_port() -> int:\n",[300,1090,1091],{"class":302,"line":411},[300,1092,1093],{},"    # Bind to port 0 and let the OS choose; hardcoding a port makes parallel\n",[300,1095,1096],{"class":302,"line":417},[300,1097,1098],{},"    # workers collide and produces \"address already in use\" in CI only.\n",[300,1100,1101],{"class":302,"line":423},[300,1102,1103],{},"    with socket.socket() as sock:\n",[300,1105,1106],{"class":302,"line":429},[300,1107,1108],{},"        sock.bind((\"127.0.0.1\", 0))\n",[300,1110,1112],{"class":302,"line":1111},15,[300,1113,1114],{},"        return sock.getsockname()[1]\n",[300,1116,1118],{"class":302,"line":1117},16,[300,1119,370],{"emptyLinePlaceholder":369},[300,1121,1123],{"class":302,"line":1122},17,[300,1124,370],{"emptyLinePlaceholder":369},[300,1126,1128],{"class":302,"line":1127},18,[300,1129,379],{},[300,1131,1133],{"class":302,"line":1132},19,[300,1134,1135],{},"async def live_server():\n",[300,1137,1139],{"class":302,"line":1138},20,[300,1140,1141],{},"    port = _free_port()\n",[300,1143,1145],{"class":302,"line":1144},21,[300,1146,1147],{},"    config = uvicorn.Config(app, host=\"127.0.0.1\", port=port, log_level=\"warning\")\n",[300,1149,1151],{"class":302,"line":1150},22,[300,1152,1153],{},"    server = uvicorn.Server(config)\n",[300,1155,1157],{"class":302,"line":1156},23,[300,1158,1159],{},"    task = asyncio.create_task(server.serve())          # runs on the session loop\n",[300,1161,1163],{"class":302,"line":1162},24,[300,1164,370],{"emptyLinePlaceholder":369},[300,1166,1168],{"class":302,"line":1167},25,[300,1169,1170],{},"    # Wait for readiness by polling the server's own flag, not by sleeping.\n",[300,1172,1174],{"class":302,"line":1173},26,[300,1175,1176],{},"    for _ in range(200):\n",[300,1178,1180],{"class":302,"line":1179},27,[300,1181,1182],{},"        if server.started:\n",[300,1184,1186],{"class":302,"line":1185},28,[300,1187,1188],{},"            break\n",[300,1190,1192],{"class":302,"line":1191},29,[300,1193,1194],{},"        await asyncio.sleep(0.01)\n",[300,1196,1198],{"class":302,"line":1197},30,[300,1199,1200],{},"    else:\n",[300,1202,1204],{"class":302,"line":1203},31,[300,1205,1206],{},"        raise RuntimeError(\"server did not start within 2 seconds\")\n",[300,1208,1210],{"class":302,"line":1209},32,[300,1211,370],{"emptyLinePlaceholder":369},[300,1213,1215],{"class":302,"line":1214},33,[300,1216,414],{},[300,1218,1220],{"class":302,"line":1219},34,[300,1221,1222],{},"        yield f\"http:\u002F\u002F127.0.0.1:{port}\"\n",[300,1224,1226],{"class":302,"line":1225},35,[300,1227,426],{},[300,1229,1231],{"class":302,"line":1230},36,[300,1232,1233],{},"        server.should_exit = True\n",[300,1235,1237],{"class":302,"line":1236},37,[300,1238,1239],{},"        await task                                      # teardown on the same loop\n",[10,1241,1242,1243,1246,1247,1251],{},"Three details make this fixture survive a large suite. The port is chosen by the operating system, so parallel workers never collide. Readiness is a poll on the server's own ",[14,1244,1245],{},"started"," flag with a bounded retry count, not a sleep — the same discipline as ",[71,1248,1250],{"href":1249},"\u002Fintegration-database-and-service-testing\u002Fspinning-up-services-with-testcontainers\u002Fwaiting-for-container-readiness-without-sleep\u002F","waiting for container readiness without sleep",". And the shutdown awaits the serve task rather than cancelling it, so uvicorn's own cleanup runs and the port is released before the next module needs one.",[10,1253,1254,1255,1258],{},"The client that talks to this server should be function-scoped on the same loop. A session-scoped ",[14,1256,1257],{},"httpx.AsyncClient"," accumulates connection-pool state across tests, which is precisely the kind of hidden coupling that makes a suite order-dependent.",[27,1260,1262],{"id":1261},"migrating-from-the-pre-10-plugin","Migrating from the pre-1.0 plugin",[10,1264,1265,1266,1268,1269,66],{},"Suites written against ",[14,1267,24],{}," 0.21 and earlier carry two patterns that are now errors rather than warnings, and both appear in almost every older ",[14,1270,1271],{},"conftest.py",[10,1273,1274,1275,1277],{},"The first is a custom ",[14,1276,84],{}," fixture, usually widened to session scope to make a pool work:",[291,1279,1281],{"className":350,"code":1280,"language":352,"meta":296,"style":296},"# BEFORE — removed in pytest-asyncio 1.0, raises at collection\nimport asyncio\n\nimport pytest\n\n\n@pytest.fixture(scope=\"session\")\ndef event_loop():\n    loop = asyncio.new_event_loop()\n    yield loop\n    loop.close()\n",[14,1282,1283,1288,1292,1296,1300,1304,1308,1312,1317,1322,1327],{"__ignoreMap":296},[300,1284,1285],{"class":302,"line":303},[300,1286,1287],{},"# BEFORE — removed in pytest-asyncio 1.0, raises at collection\n",[300,1289,1290],{"class":302,"line":309},[300,1291,642],{},[300,1293,1294],{"class":302,"line":315},[300,1295,370],{"emptyLinePlaceholder":369},[300,1297,1298],{"class":302,"line":321},[300,1299,522],{},[300,1301,1302],{"class":302,"line":327},[300,1303,370],{"emptyLinePlaceholder":369},[300,1305,1306],{"class":302,"line":333},[300,1307,370],{"emptyLinePlaceholder":369},[300,1309,1310],{"class":302,"line":387},[300,1311,597],{},[300,1313,1314],{"class":302,"line":393},[300,1315,1316],{},"def event_loop():\n",[300,1318,1319],{"class":302,"line":399},[300,1320,1321],{},"    loop = asyncio.new_event_loop()\n",[300,1323,1324],{"class":302,"line":405},[300,1325,1326],{},"    yield loop\n",[300,1328,1329],{"class":302,"line":411},[300,1330,1331],{},"    loop.close()\n",[291,1333,1335],{"className":350,"code":1334,"language":352,"meta":296,"style":296},"# AFTER — express the same intent as configuration, not as a fixture override\n# pyproject.toml:\n#   [tool.pytest.ini_options]\n#   asyncio_default_fixture_loop_scope = \"session\"\n#\n# and mark the tests that need it:\nimport pytest\n\npytestmark = pytest.mark.asyncio(loop_scope=\"session\")\n",[14,1336,1337,1342,1347,1352,1357,1362,1367,1371,1375],{"__ignoreMap":296},[300,1338,1339],{"class":302,"line":303},[300,1340,1341],{},"# AFTER — express the same intent as configuration, not as a fixture override\n",[300,1343,1344],{"class":302,"line":309},[300,1345,1346],{},"# pyproject.toml:\n",[300,1348,1349],{"class":302,"line":315},[300,1350,1351],{},"#   [tool.pytest.ini_options]\n",[300,1353,1354],{"class":302,"line":321},[300,1355,1356],{},"#   asyncio_default_fixture_loop_scope = \"session\"\n",[300,1358,1359],{"class":302,"line":327},[300,1360,1361],{},"#\n",[300,1363,1364],{"class":302,"line":333},[300,1365,1366],{},"# and mark the tests that need it:\n",[300,1368,1369],{"class":302,"line":387},[300,1370,522],{},[300,1372,1373],{"class":302,"line":393},[300,1374,370],{"emptyLinePlaceholder":369},[300,1376,1377],{"class":302,"line":399},[300,1378,536],{},[10,1380,1381,1382,1384,1385,1387],{},"The replacement is better than the thing it replaces, because the old override applied to everything: one fixture in ",[14,1383,1271],{}," silently moved the entire suite onto a session loop, including tests that wanted isolation. ",[14,1386,47],{}," is declared per test or per module, so widening is a local decision with a visible marker.",[111,1389,1391,1450],{"className":1390},[114],[116,1392,124,1397,124,1400,124,1403,124,1406,124,1410,124,1416,124,1421,124,1426,124,1429,124,1433,124,1436,124,1440,124,1444,124,1447],{"viewBox":1393,"role":119,"ariaLabelledBy":1394,"xmlns":123},"0 0 800 226",[1395,1396],"mig-t","mig-d",[126,1398,1399],{"id":1395},"From an event_loop override to loop_scope",[130,1401,1402],{"id":1396},"Before migration, a session-scoped event_loop fixture in conftest silently moves every test in the suite onto one shared loop. After migration, asyncio_default_fixture_loop_scope sets fixture loops in configuration and a loop_scope marker widens only the modules that ask for it, leaving other tests isolated.",[134,1404],{"x":136,"y":136,"width":929,"height":1405,"rx":139,"fill":140},"226",[142,1407,1409],{"x":933,"y":145,"textAnchor":146,"fontSize":1408,"fontWeight":148,"fill":149},"15.5","Global override becomes a local, visible choice",[134,1411],{"x":1412,"y":1413,"width":1414,"height":1415,"rx":155,"fill":211,"stroke":212,"strokeWidth":181},"26","50","360","152",[142,1417,1420],{"x":1418,"y":1419,"textAnchor":146,"fontSize":947,"fontWeight":148,"fill":149},"206","78","before · event_loop override",[142,1422,1425],{"x":1423,"y":1424,"fontSize":189,"fill":149},"44","108","one fixture in conftest.py",[142,1427,1428],{"x":1423,"y":166,"fontSize":189,"fill":149},"every test on one session loop",[142,1430,1432],{"x":1423,"y":1431,"fontSize":189,"fontWeight":148,"fill":224},"176","error in pytest-asyncio 1.0",[134,1434],{"x":1435,"y":1413,"width":1414,"height":1415,"rx":155,"fill":179,"stroke":180,"strokeWidth":181},"414",[142,1437,1439],{"x":1438,"y":1419,"textAnchor":146,"fontSize":947,"fontWeight":148,"fill":149},"594","after · loop_scope",[142,1441,1443],{"x":1442,"y":1424,"fontSize":189,"fill":149},"432","fixture loop scope in config",[142,1445,1446],{"x":1442,"y":166,"fontSize":189,"fill":149},"marker on modules that need it",[142,1448,1449],{"x":1442,"y":1431,"fontSize":189,"fontWeight":148,"fill":194},"other tests stay isolated",[238,1451,1452],{},"The same intent, expressed where a reviewer can see which tests share a loop.",[10,1454,1455,1456,1458,1459,1461,1462,1465],{},"The second pattern is the ",[14,1457,264],{}," decorator on every test in a strict-mode suite. Switching to ",[14,1460,791],{}," deletes those decorators wholesale, but only do it after confirming no other async plugin is installed — ",[14,1463,1464],{},"pip list | grep -E \"anyio|trio|tornado\""," takes a second and prevents the silent double-collection described above.",[10,1467,1468,1469,1471,1472,1474,1475,1478],{},"A migration is worth doing in one change rather than incrementally. Mixed configuration, where half the suite relies on an ",[14,1470,84],{}," override and half on ",[14,1473,47],{},", produces loop lifetimes that depend on collection order, and collection order is exactly what changes when someone adds a file. Run the whole suite once with ",[14,1476,1477],{},"-W error"," afterwards; the deprecations that remain are the ones that will break at the next upgrade, and fixing them while the change is fresh costs far less than diagnosing them in six months.",[27,1480,1482],{"id":1481},"what-loop-setup-actually-costs","What loop setup actually costs",[10,1484,1485,1486,1488,1489,1492],{},"Widening loop scope is usually justified on performance grounds, so it is worth knowing the size of the effect before restructuring a suite around it. Creating and closing an ",[14,1487,860],{}," loop is cheap — on the order of a hundred microseconds — and on its own is never the reason a suite is slow. What is expensive is everything a fixture does ",[97,1490,1491],{},"inside"," that loop: TLS handshakes, connection pool warm-up, schema reflection, an application's startup event handlers.",[10,1494,1495],{},"That distinction determines where widening pays. A suite of 500 tests with a function-scoped loop and no async fixtures loses about 50 ms in total to loop churn, which is noise. The same suite where each test opens a Postgres connection loses 500 × 15 ms, or seven and a half seconds; where each test starts an application with its own startup hooks, the figure is minutes.",[291,1497,1501],{"className":1498,"code":1499,"language":1500,"meta":296,"style":296},"language-bash shiki shiki-themes github-light github-dark","# Where the time goes: setup, call, or teardown, per test.\npytest --durations=20 --durations-min=0.05 -q\n","bash",[14,1502,1503,1509],{"__ignoreMap":296},[300,1504,1505],{"class":302,"line":303},[300,1506,1508],{"class":1507},"sJ8bj","# Where the time goes: setup, call, or teardown, per test.\n",[300,1510,1511,1515,1519,1522],{"class":302,"line":309},[300,1512,1514],{"class":1513},"sScJk","pytest",[300,1516,1518],{"class":1517},"sj4cs"," --durations=20",[300,1520,1521],{"class":1517}," --durations-min=0.05",[300,1523,1524],{"class":1517}," -q\n",[291,1526,1529],{"className":1527,"code":1528,"language":142,"meta":296},[697],"12.41s setup    tests\u002Fapi\u002Ftest_orders.py::test_create_order\n11.98s setup    tests\u002Fapi\u002Ftest_orders.py::test_cancel_order\n 0.31s call     tests\u002Fapi\u002Ftest_orders.py::test_create_order\n",[14,1530,1528],{"__ignoreMap":296},[10,1532,1533,1534,66],{},"Setup dominating the call phase by two orders of magnitude is the signature that says \"widen the fixture, and its loop with it\". Call-phase dominance says the opposite: the loop is not the problem, and the fix is elsewhere — removing sleeps, faking a slow dependency, or the profiling workflow in ",[71,1535,1537],{"href":1536},"\u002Fsystematic-debugging-performance-profiling\u002Fcpu-profiling-with-cprofile-and-py-spy\u002F","CPU profiling with cProfile and py-spy",[10,1539,1540],{},"One caveat applies to every widening decision. A session-scoped loop means a session-scoped failure domain: a test that leaves a task pending, a lock held, or a queue full hands that state to every test after it. The safeguard is cheap — an autouse function-scoped fixture that asserts the loop is clean when a test ends:",[291,1542,1544],{"className":350,"code":1543,"language":352,"meta":296,"style":296},"import asyncio\n\nimport pytest\n\n\n@pytest.fixture(autouse=True)\nasync def no_leaked_tasks():\n    yield\n    # Anything still pending after the test belongs to the test, not to the loop.\n    pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]\n    assert not pending, f\"test leaked {len(pending)} pending task(s): {pending}\"\n",[14,1545,1546,1550,1554,1558,1562,1566,1571,1576,1581,1586,1591],{"__ignoreMap":296},[300,1547,1548],{"class":302,"line":303},[300,1549,642],{},[300,1551,1552],{"class":302,"line":309},[300,1553,370],{"emptyLinePlaceholder":369},[300,1555,1556],{"class":302,"line":315},[300,1557,522],{},[300,1559,1560],{"class":302,"line":321},[300,1561,370],{"emptyLinePlaceholder":369},[300,1563,1564],{"class":302,"line":327},[300,1565,370],{"emptyLinePlaceholder":369},[300,1567,1568],{"class":302,"line":333},[300,1569,1570],{},"@pytest.fixture(autouse=True)\n",[300,1572,1573],{"class":302,"line":387},[300,1574,1575],{},"async def no_leaked_tasks():\n",[300,1577,1578],{"class":302,"line":393},[300,1579,1580],{},"    yield\n",[300,1582,1583],{"class":302,"line":399},[300,1584,1585],{},"    # Anything still pending after the test belongs to the test, not to the loop.\n",[300,1587,1588],{"class":302,"line":405},[300,1589,1590],{},"    pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]\n",[300,1592,1593],{"class":302,"line":411},[300,1594,1595],{},"    assert not pending, f\"test leaked {len(pending)} pending task(s): {pending}\"\n",[10,1597,1598,1599,66],{},"This turns \"the suite is flaky after test 40\" into \"test 39 leaks a task\", which is a one-line diagnosis instead of a bisection. Keeping it autouse costs microseconds per test and is worth it from the first shared loop onward; the trade-offs of autouse fixtures in general are covered in ",[71,1600,1602],{"href":1601},"\u002Fadvanced-pytest-architecture-configuration\u002Fmastering-pytest-fixtures\u002Ftaming-autouse-fixtures-in-large-suites\u002F","taming autouse fixtures in large suites",[27,1604,1606],{"id":1605},"frequently-asked-questions","Frequently Asked Questions",[10,1608,1609,1613,1615,1616,1618,1619,1621,1622,1624,1625,1627],{},[1610,1611,1612],"strong",{},"What is the difference between scope and loop_scope on an async fixture?",[14,1614,105],{}," decides how often the fixture body runs; ",[14,1617,47],{}," decides which event loop it runs on. A function-scoped fixture with ",[14,1620,758],{}," executes once per test but always on the session loop, so objects it creates can safely be handed to other session-loop fixtures. Setting ",[14,1623,105],{}," without ",[14,1626,47],{}," leaves the fixture on a function loop that dies after each test.",[10,1629,1630,1633,1635,1636,1638,1639,1641,1642,1644,1645,1647],{},[1610,1631,1632],{},"Why did the event_loop fixture stop working?",[14,1634,24],{}," deprecated overriding ",[14,1637,84],{}," in 0.23 and removed it in 1.0. Redefining it now raises an error at collection. Replace a custom ",[14,1640,84],{}," with the ",[14,1643,47],{}," argument for lifetime, and with an ",[14,1646,824],{}," fixture when you need a different loop implementation such as uvloop.",[10,1649,1650,1653,1654,1656,1657,1659,1660,1663,1664,1666,1667,1670],{},[1610,1651,1652],{},"Does asyncio_mode=auto affect tests written for other async plugins?","\nYes, and that is the main hazard. In auto mode ",[14,1655,24],{}," claims every coroutine test function it can see, including ones intended for ",[14,1658,275],{}," or ",[14,1661,1662],{},"trio",". Keep backend-parametrised tests in a directory whose ",[14,1665,1271],{}," sets ",[14,1668,1669],{},"asyncio_mode=strict",", or run them as a separate invocation.",[10,1672,1673,1676,1677,1679,1680,1683,1684,1686],{},[1610,1674,1675],{},"How do I run tests on uvloop instead of the default loop?","\nDefine an ",[14,1678,824],{}," fixture returning ",[14,1681,1682],{},"uvloop.EventLoopPolicy()"," at the scope you want it applied. ",[14,1685,24],{}," creates its loops through the policy, so every loop at that scope and below uses uvloop without any test changing.",[10,1688,1689,1692],{},[1610,1690,1691],{},"Can a synchronous fixture be used by an async test?","\nYes, and it should be whenever the fixture does no awaiting. Synchronous fixtures have no loop affinity at all, which makes them immune to every scoping problem in this guide. Only make a fixture async when it genuinely needs to await something.",[27,1694,1696],{"id":1695},"related-guides","Related guides",[32,1698,1699,1706,1713,1720,1727],{},[35,1700,1701,1702,66],{},"Decide the collection mode deliberately with ",[71,1703,1705],{"href":1704},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Fconfiguring-asyncio-mode-auto-versus-strict\u002F","configuring asyncio_mode: auto versus strict",[35,1707,1708,1709,66],{},"Share one loop across a module using ",[71,1710,1712],{"href":1711},"\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",[35,1714,1715,1716,66],{},"Get teardown right for streaming resources in ",[71,1717,1719],{"href":1718},"\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Ftesting-async-generators-and-context-managers\u002F","testing async generators and context managers",[35,1721,1722,1723,66],{},"Compare the plugin with the backend-agnostic alternative in ",[71,1724,1726],{"href":1725},"\u002Fadvanced-pytest-architecture-configuration\u002Fmastering-pytest-fixtures\u002Fpytest-asyncio-vs-anyio-scoping-trade-offs\u002F","pytest-asyncio vs anyio scoping trade-offs",[35,1728,1729,1730,66],{},"When a loop error survives the fixes here, move to ",[71,1731,1733],{"href":1732},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-async-code-and-event-loops\u002Fdebugging-event-loop-is-closed-runtimeerror\u002F","debugging the event loop is closed RuntimeError",[10,1735,1736,1737],{},"← Back to ",[71,1738,1740],{"href":1739},"\u002Ftesting-async-and-concurrent-python\u002F","Testing Async & Concurrent Python",[1742,1743,1744],"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 .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}",{"title":296,"searchDepth":309,"depth":309,"links":1746},[1747,1748,1749,1756,1757,1758,1759,1760,1761,1762,1763],{"id":29,"depth":309,"text":30},{"id":88,"depth":309,"text":89},{"id":283,"depth":309,"text":284,"children":1750},[1751,1752,1753,1754,1755],{"id":288,"depth":315,"text":289},{"id":346,"depth":315,"text":347},{"id":435,"depth":315,"text":436},{"id":511,"depth":315,"text":512},{"id":569,"depth":315,"text":570},{"id":628,"depth":309,"text":629},{"id":718,"depth":309,"text":719},{"id":850,"depth":309,"text":851},{"id":1035,"depth":309,"text":1036},{"id":1261,"depth":309,"text":1262},{"id":1481,"depth":309,"text":1482},{"id":1605,"depth":309,"text":1606},{"id":1695,"depth":309,"text":1696},"Master pytest-asyncio: asyncio_mode collection rules, loop_scope versus fixture scope, async fixture teardown, and the loop-affinity errors that break suites at scale.","md",{"slug":1767,"type":1768,"breadcrumb":24,"datePublished":1769,"dateModified":1769,"faq":1770,"howto":1781},"pytest-asyncio-in-depth","topic","2026-09-18",[1771,1773,1775,1777,1779],{"q":1612,"a":1772},"scope decides how often the fixture body runs; loop_scope decides which event loop it runs on. A function-scoped fixture with loop_scope='session' executes once per test but always on the session loop, so objects it creates can safely be handed to other session-loop fixtures. Setting scope without loop_scope leaves the fixture on a function loop that dies after each test.",{"q":1632,"a":1774},"pytest-asyncio deprecated overriding event_loop in 0.23 and removed it in 1.0. Redefining it now raises an error at collection. Replace a custom event_loop with the loop_scope argument for lifetime, and with an event_loop_policy fixture when you need a different loop implementation such as uvloop.",{"q":1652,"a":1776},"Yes, and that is the main hazard. In auto mode pytest-asyncio claims every coroutine test function it can see, including ones intended for anyio or trio. Keep backend-parametrised tests in a directory whose conftest.py sets asyncio_mode=strict, or run them as a separate invocation.",{"q":1675,"a":1778},"Define an event_loop_policy fixture returning uvloop.EventLoopPolicy() at the scope you want it applied. pytest-asyncio creates its loops through the policy, so every loop at that scope and below uses uvloop without any test changing.",{"q":1691,"a":1780},"Yes, and it should be whenever the fixture does no awaiting. Synchronous fixtures have no loop affinity at all, which makes them immune to every scoping problem in this guide. Only make a fixture async when it genuinely needs to await something.",{"name":1782,"description":1783,"steps":1784},"How to configure pytest-asyncio for a large suite","Set collection mode once, match loop scope to resource lifetime, and verify the loop identity before the suite grows.",[1785,1788,1791,1794,1797],{"name":1786,"text":1787},"Pin the plugin and set the collection mode","Add pytest-asyncio>=0.24 and set asyncio_mode = auto in pyproject.toml so coroutine tests are collected without per-test markers.",{"name":1789,"text":1790},"Choose a default loop scope","Set asyncio_default_fixture_loop_scope explicitly rather than relying on the deprecation default, so the configuration survives a plugin upgrade.",{"name":1792,"text":1793},"Match expensive fixtures to a wider loop","Give connection pools and servers scope='session' together with loop_scope='session' so the resource and the loop that owns its sockets die together.",{"name":1795,"text":1796},"Prove the loop identity","Assert on id(asyncio.get_running_loop()) in a temporary test to confirm which tests share a loop before relying on it.",{"name":1798,"text":1799},"Guard against cross-plugin collection","Keep anyio or trio tests in a directory whose conftest.py switches back to strict mode so two plugins never claim the same test.","\u002Ftesting-async-and-concurrent-python\u002Fpytest-asyncio-in-depth",{"title":5,"description":1764},"testing-async-and-concurrent-python\u002Fpytest-asyncio-in-depth\u002Findex","KlRYb654s0JGpyNjOA_b-9CyTMxwFcQkHVrrSjiMwp4",1789718765719]