[{"data":1,"prerenderedAt":974},["ShallowReactive",2],{"page-\u002Fsystematic-debugging-performance-profiling\u002Fmemory-profiling-with-tracemalloc\u002Fcatching-per-test-memory-growth-in-pytest\u002F":3},{"id":4,"title":5,"body":6,"description":940,"extension":941,"meta":942,"navigation":96,"path":970,"seo":971,"stem":972,"__hash__":973},"content\u002Fsystematic-debugging-performance-profiling\u002Fmemory-profiling-with-tracemalloc\u002Fcatching-per-test-memory-growth-in-pytest\u002Findex.md","Catching Per-Test Memory Growth in pytest",{"type":7,"value":8,"toc":928},"minimark",[9,18,21,26,52,56,278,323,457,461,480,483,487,494,509,572,576,587,642,660,752,756,759,770,800,804,811,815,865,869,875,881,887,891,919,924],[10,11,12,13,17],"p",{},"Memory leaks in test suites announce themselves in unhelpful ways. The CI job's memory climbs steadily until the runner kills it at the 80th percentile of the suite, or ",[14,15,16],"code",{},"pytest-xdist"," workers start dying with no traceback, or the whole run just gets slower as the garbage collector works harder. The symptom appears at the end; the cause is some test, or the code it exercises, that keeps a little memory alive every time it runs. With several thousand tests, finding it by bisection takes an afternoon.",[10,19,20],{},"A measuring fixture finds it in one run. Record memory before and after every test, collect garbage first so only genuinely retained memory counts, and report the tests with the largest growth at the end of the session. Most results are harmless one-off caches; the few that matter show up clearly once you run them repeatedly and see the growth scale with the number of runs.",[22,23,25],"h2",{"id":24},"prerequisites","Prerequisites",[27,28,29,44],"ul",{},[30,31,32,35,36,39,40,43],"li",{},[14,33,34],{},"pytest >= 8.0",", ",[14,37,38],{},"psutil >= 5.9",", optionally ",[14,41,42],{},"pytest-repeat",".",[30,45,46,47,43],{},"Background from ",[48,49,51],"a",{"href":50},"\u002Fsystematic-debugging-performance-profiling\u002Fmemory-profiling-with-tracemalloc\u002F","Memory profiling with tracemalloc",[22,53,55],{"id":54},"solution","Solution",[57,58,63],"pre",{"className":59,"code":60,"language":61,"meta":62,"style":62},"language-python shiki shiki-themes github-light github-dark","# conftest.py — opt-in with: pytest --memgrowth\nimport gc\nimport os\nimport tracemalloc\n\nimport psutil\nimport pytest\n\n_growth: list[tuple[int, int, str]] = []\n_proc = psutil.Process(os.getpid())\n\ndef pytest_addoption(parser):\n    parser.addoption(\"--memgrowth\", action=\"store_true\", help=\"report per-test memory growth\")\n\n@pytest.fixture(autouse=True)\ndef _measure_memory(request):\n    if not request.config.getoption(\"--memgrowth\"):\n        yield\n        return\n    if not tracemalloc.is_tracing():\n        tracemalloc.start(10)\n    gc.collect()\n    traced_before, _ = tracemalloc.get_traced_memory()\n    rss_before = _proc.memory_info().rss\n    yield\n    gc.collect()\n    traced_after, _ = tracemalloc.get_traced_memory()\n    rss_after = _proc.memory_info().rss\n    _growth.append((traced_after - traced_before, rss_after - rss_before, request.node.nodeid))\n\ndef pytest_terminal_summary(terminalreporter):\n    if not _growth:\n        return\n    terminalreporter.section(\"memory growth (retained after test)\")\n    for traced, rss, nodeid in sorted(_growth, reverse=True)[:15]:\n        terminalreporter.write_line(f\"{traced \u002F 1024:>9.1f} KiB traced  {rss \u002F 1024:>9.1f} KiB rss  {nodeid}\")\n","python","",[14,64,65,73,79,85,91,98,104,110,115,121,127,132,138,144,149,155,161,167,173,179,185,191,197,203,209,215,220,226,232,238,243,249,255,260,266,272],{"__ignoreMap":62},[66,67,70],"span",{"class":68,"line":69},"line",1,[66,71,72],{},"# conftest.py — opt-in with: pytest --memgrowth\n",[66,74,76],{"class":68,"line":75},2,[66,77,78],{},"import gc\n",[66,80,82],{"class":68,"line":81},3,[66,83,84],{},"import os\n",[66,86,88],{"class":68,"line":87},4,[66,89,90],{},"import tracemalloc\n",[66,92,94],{"class":68,"line":93},5,[66,95,97],{"emptyLinePlaceholder":96},true,"\n",[66,99,101],{"class":68,"line":100},6,[66,102,103],{},"import psutil\n",[66,105,107],{"class":68,"line":106},7,[66,108,109],{},"import pytest\n",[66,111,113],{"class":68,"line":112},8,[66,114,97],{"emptyLinePlaceholder":96},[66,116,118],{"class":68,"line":117},9,[66,119,120],{},"_growth: list[tuple[int, int, str]] = []\n",[66,122,124],{"class":68,"line":123},10,[66,125,126],{},"_proc = psutil.Process(os.getpid())\n",[66,128,130],{"class":68,"line":129},11,[66,131,97],{"emptyLinePlaceholder":96},[66,133,135],{"class":68,"line":134},12,[66,136,137],{},"def pytest_addoption(parser):\n",[66,139,141],{"class":68,"line":140},13,[66,142,143],{},"    parser.addoption(\"--memgrowth\", action=\"store_true\", help=\"report per-test memory growth\")\n",[66,145,147],{"class":68,"line":146},14,[66,148,97],{"emptyLinePlaceholder":96},[66,150,152],{"class":68,"line":151},15,[66,153,154],{},"@pytest.fixture(autouse=True)\n",[66,156,158],{"class":68,"line":157},16,[66,159,160],{},"def _measure_memory(request):\n",[66,162,164],{"class":68,"line":163},17,[66,165,166],{},"    if not request.config.getoption(\"--memgrowth\"):\n",[66,168,170],{"class":68,"line":169},18,[66,171,172],{},"        yield\n",[66,174,176],{"class":68,"line":175},19,[66,177,178],{},"        return\n",[66,180,182],{"class":68,"line":181},20,[66,183,184],{},"    if not tracemalloc.is_tracing():\n",[66,186,188],{"class":68,"line":187},21,[66,189,190],{},"        tracemalloc.start(10)\n",[66,192,194],{"class":68,"line":193},22,[66,195,196],{},"    gc.collect()\n",[66,198,200],{"class":68,"line":199},23,[66,201,202],{},"    traced_before, _ = tracemalloc.get_traced_memory()\n",[66,204,206],{"class":68,"line":205},24,[66,207,208],{},"    rss_before = _proc.memory_info().rss\n",[66,210,212],{"class":68,"line":211},25,[66,213,214],{},"    yield\n",[66,216,218],{"class":68,"line":217},26,[66,219,196],{},[66,221,223],{"class":68,"line":222},27,[66,224,225],{},"    traced_after, _ = tracemalloc.get_traced_memory()\n",[66,227,229],{"class":68,"line":228},28,[66,230,231],{},"    rss_after = _proc.memory_info().rss\n",[66,233,235],{"class":68,"line":234},29,[66,236,237],{},"    _growth.append((traced_after - traced_before, rss_after - rss_before, request.node.nodeid))\n",[66,239,241],{"class":68,"line":240},30,[66,242,97],{"emptyLinePlaceholder":96},[66,244,246],{"class":68,"line":245},31,[66,247,248],{},"def pytest_terminal_summary(terminalreporter):\n",[66,250,252],{"class":68,"line":251},32,[66,253,254],{},"    if not _growth:\n",[66,256,258],{"class":68,"line":257},33,[66,259,178],{},[66,261,263],{"class":68,"line":262},34,[66,264,265],{},"    terminalreporter.section(\"memory growth (retained after test)\")\n",[66,267,269],{"class":68,"line":268},35,[66,270,271],{},"    for traced, rss, nodeid in sorted(_growth, reverse=True)[:15]:\n",[66,273,275],{"class":68,"line":274},36,[66,276,277],{},"        terminalreporter.write_line(f\"{traced \u002F 1024:>9.1f} KiB traced  {rss \u002F 1024:>9.1f} KiB rss  {nodeid}\")\n",[57,279,283],{"className":280,"code":281,"language":282,"meta":62,"style":62},"language-bash shiki shiki-themes github-light github-dark","pytest --memgrowth -p no:randomly -q\n# then confirm a suspect:\npytest --memgrowth --count=50 \"tests\u002Ftest_export.py::test_csv_export\"\n","bash",[14,284,285,305,311],{"__ignoreMap":62},[66,286,287,291,295,298,302],{"class":68,"line":69},[66,288,290],{"class":289},"sScJk","pytest",[66,292,294],{"class":293},"sj4cs"," --memgrowth",[66,296,297],{"class":293}," -p",[66,299,301],{"class":300},"sZZnC"," no:randomly",[66,303,304],{"class":293}," -q\n",[66,306,307],{"class":68,"line":75},[66,308,310],{"class":309},"sJ8bj","# then confirm a suspect:\n",[66,312,313,315,317,320],{"class":68,"line":81},[66,314,290],{"class":289},[66,316,294],{"class":293},[66,318,319],{"class":293}," --count=50",[66,321,322],{"class":300}," \"tests\u002Ftest_export.py::test_csv_export\"\n",[324,325,328,453],"figure",{"className":326},[327],"diagram",[329,330,337,338,337,342,337,346,337,364,337,372,337,382,337,392,337,398,337,403,337,406,337,412,337,415,337,418,337,421,337,426,337,430,337,433,337,440,337,444,337,448],"svg",{"viewBox":331,"role":332,"ariaLabelledBy":333,"xmlns":336},"0 0 800 246","img",[334,335],"pg-t","pg-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[339,340,341],"title",{"id":334},"Measuring retained memory around each test",[343,344,345],"desc",{"id":335},"For each test, the fixture runs a garbage collection and records traced memory and resident set size, lets the test run, collects again and records both values a second time. The differences are stored per test and the largest are printed in the terminal summary at the end of the session.",[347,348,349,350,337],"defs",{},"\n    ",[351,352,359],"marker",{"id":353,"viewBox":354,"refX":355,"refY":356,"markerWidth":357,"markerHeight":357,"orient":358},"pg-a","0 0 10 10","9","5","7","auto-start-reverse",[360,361],"path",{"d":362,"fill":363},"M0 0 L10 5 L0 10 z","#81b29a",[365,366],"rect",{"x":367,"y":367,"width":368,"height":369,"rx":370,"fill":371},"0","800","246","14","#fffdf8",[373,374,381],"text",{"x":375,"y":376,"textAnchor":377,"fontSize":378,"fontWeight":379,"fill":380},"400","28","middle","15.5","700","#3d405b","Collect, measure, run, collect, measure",[365,383],{"x":384,"y":385,"width":386,"height":387,"rx":388,"fill":389,"stroke":390,"strokeWidth":391},"20","86","140","62","10","#f7f0da","#f2cc8f","2",[373,393,397],{"x":394,"y":395,"textAnchor":377,"fontSize":396,"fontWeight":379,"fill":380},"90","112","11.5","gc.collect()",[373,399,402],{"x":394,"y":400,"textAnchor":377,"fontSize":401,"fill":380},"132","10.5","measure before",[365,404],{"x":405,"y":385,"width":386,"height":387,"rx":388,"fill":380},"190",[373,407,411],{"x":408,"y":409,"textAnchor":377,"fontSize":410,"fontWeight":379,"fill":371},"260","122","12","test runs",[365,413],{"x":414,"y":385,"width":386,"height":387,"rx":388,"fill":389,"stroke":390,"strokeWidth":391},"360",[373,416,397],{"x":417,"y":395,"textAnchor":377,"fontSize":396,"fontWeight":379,"fill":380},"430",[373,419,420],{"x":417,"y":400,"textAnchor":377,"fontSize":401,"fill":380},"measure after",[365,422],{"x":423,"y":385,"width":424,"height":387,"rx":388,"fill":425,"stroke":363,"strokeWidth":391},"530","244","#e6f0ea",[373,427,429],{"x":428,"y":395,"textAnchor":377,"fontSize":396,"fontWeight":379,"fill":380},"652","record delta",[373,431,432],{"x":428,"y":400,"textAnchor":377,"fontSize":401,"fill":380},"traced · rss · nodeid",[68,434],{"x1":435,"y1":436,"x2":437,"y2":436,"stroke":363,"strokeWidth":438,"markerEnd":439},"162","117","186","1.8","url(#pg-a)",[68,441],{"x1":442,"y1":436,"x2":443,"y2":436,"stroke":363,"strokeWidth":438,"markerEnd":439},"332","356",[68,445],{"x1":446,"y1":436,"x2":447,"y2":436,"stroke":363,"strokeWidth":438,"markerEnd":439},"502","526",[373,449,452],{"x":375,"y":450,"textAnchor":377,"fontSize":451,"fill":380},"196","11","At session end: the fifteen largest deltas, printed in the terminal summary.",[454,455,456],"figcaption",{},"Collecting before both measurements means cyclic garbage waiting for the collector never counts as growth.",[22,458,460],{"id":459},"why-this-works","Why this works",[10,462,463,464,467,468,471,472,475,476,43],{},"The two measurements complement each other. ",[14,465,466],{},"tracemalloc.get_traced_memory()"," returns the bytes currently allocated through Python's allocator — exact, unaffected by allocator caching, but blind to memory allocated by C extensions. RSS from ",[14,469,470],{},"psutil"," counts every page the process holds, native or not, but it is coarse: Python's small-object allocator and glibc's ",[14,473,474],{},"malloc"," both keep freed memory for reuse, so RSS often stays flat when objects are freed and jumps in steps when new arenas are mapped. A test that shows traced growth has retained Python objects; a test that shows RSS growth with no traced growth points at native memory, which is a job for ",[48,477,479],{"href":478},"\u002Fsystematic-debugging-performance-profiling\u002Fmemory-profiling-with-tracemalloc\u002Fprofiling-memory-with-memray\u002F","memray",[10,481,482],{},"Starting tracemalloc once, lazily, rather than per test, avoids repeatedly paying its startup cost and keeps the traced baseline continuous across the session. The frame depth of 10 costs some overhead, which is why the fixture is behind an opt-in flag: a normal run pays nothing.",[22,484,486],{"id":485},"separating-leaks-from-caches","Separating leaks from caches",[10,488,489,490,493],{},"The first report is always noisy. The first test that imports a heavy module shows megabytes of growth; the first test to call a function decorated with ",[14,491,492],{},"functools.lru_cache"," fills the cache; the first request through a web client builds a connection pool. None of that is a leak. It happens once and then stays constant.",[10,495,496,497,501,502,504,505,508],{},"A leak grows ",[498,499,500],"em",{},"every time",". The confirmation step is to run a suspect repeatedly — ",[14,503,42],{},"'s ",[14,506,507],{},"--count=50"," does this — and look at the per-run deltas. A cache shows one large delta and forty-nine near zero. A leak shows fifty similar deltas, and total growth proportional to the count. That linear signature is the one that matters, because it is the one that eventually kills a long-running process.",[324,510,512,569],{"className":511},[327],[329,513,337,518,337,521,337,524,337,527,337,530,337,535,337,538,337,543,337,549,337,554,337,559,337,563],{"viewBox":514,"role":332,"ariaLabelledBy":515,"xmlns":336},"0 0 800 236",[516,517],"pgl-t","pgl-d",[339,519,520],{"id":516},"Cache versus leak under repetition",[343,522,523],{"id":517},"Two lines plot cumulative memory over fifty repetitions of one test. The cache line jumps on the first run and then stays flat. The leak line rises by a similar amount on every run, forming a straight upward slope.",[365,525],{"x":367,"y":367,"width":368,"height":526,"rx":370,"fill":371},"236",[373,528,529],{"x":375,"y":376,"textAnchor":377,"fontSize":378,"fontWeight":379,"fill":380},"Run it fifty times and look at the slope",[68,531],{"x1":532,"y1":450,"x2":533,"y2":450,"stroke":380,"strokeWidth":534},"70","760","1.6",[68,536],{"x1":532,"y1":450,"x2":532,"y2":537,"stroke":380,"strokeWidth":534},"50",[373,539,542],{"x":540,"y":541,"textAnchor":377,"fontSize":451,"fill":380},"415","222","repetition",[373,544,548],{"x":545,"y":546,"textAnchor":377,"fontSize":451,"fill":380,"transform":547},"58","124","rotate(-90 58 124)","retained memory",[360,550],{"d":551,"fill":552,"stroke":363,"strokeWidth":553},"M70 196 L90 150 L760 148","none","2.6",[373,555,558],{"x":556,"y":386,"fontSize":451,"fill":557},"600","#2a5f49","cache: one step, then flat",[360,560],{"d":561,"fill":552,"stroke":562,"strokeWidth":553},"M70 196 L760 60","#e07a5f",[373,564,568],{"x":565,"y":566,"fontSize":451,"fill":567},"560","84","#8f3d22","leak: grows every run",[454,570,571],{},"Only the straight slope needs fixing; the one-off step is a cache doing its job.",[22,573,575],{"id":574},"explaining-a-confirmed-leak","Explaining a confirmed leak",[10,577,578,579,582,583,586],{},"Once repetition confirms a leak, the question becomes ",[498,580,581],{},"what"," is being retained and ",[498,584,585],{},"who"," holds it. tracemalloc already has the first half of the answer, because it has been recording stacks. Take a snapshot after a few warm-up repetitions and another after many more, and compare them grouped by traceback:",[57,588,590],{"className":59,"code":589,"language":61,"meta":62,"style":62},"tracemalloc.start(25)\nfor _ in range(5):\n    run_suspect()                   # warm caches\ngc.collect(); first = tracemalloc.take_snapshot()\nfor _ in range(200):\n    run_suspect()\ngc.collect(); second = tracemalloc.take_snapshot()\nfor stat in second.compare_to(first, \"traceback\")[:3]:\n    print(stat)\n    print(\"\\n\".join(stat.traceback.format()))\n",[14,591,592,597,602,607,612,617,622,627,632,637],{"__ignoreMap":62},[66,593,594],{"class":68,"line":69},[66,595,596],{},"tracemalloc.start(25)\n",[66,598,599],{"class":68,"line":75},[66,600,601],{},"for _ in range(5):\n",[66,603,604],{"class":68,"line":81},[66,605,606],{},"    run_suspect()                   # warm caches\n",[66,608,609],{"class":68,"line":87},[66,610,611],{},"gc.collect(); first = tracemalloc.take_snapshot()\n",[66,613,614],{"class":68,"line":93},[66,615,616],{},"for _ in range(200):\n",[66,618,619],{"class":68,"line":100},[66,620,621],{},"    run_suspect()\n",[66,623,624],{"class":68,"line":106},[66,625,626],{},"gc.collect(); second = tracemalloc.take_snapshot()\n",[66,628,629],{"class":68,"line":112},[66,630,631],{},"for stat in second.compare_to(first, \"traceback\")[:3]:\n",[66,633,634],{"class":68,"line":117},[66,635,636],{},"    print(stat)\n",[66,638,639],{"class":68,"line":123},[66,640,641],{},"    print(\"\\n\".join(stat.traceback.format()))\n",[10,643,644,645,648,649,652,653,656,657,43],{},"The top entry is typically a single allocation site whose count grew by exactly 200, or a multiple of it — one leaked object per run. Its traceback shows where the object was created. The second half of the answer — what keeps it alive — comes from the garbage collector: ",[14,646,647],{},"gc.get_referrers(obj)"," on one of those objects, or ",[14,650,651],{},"objgraph.show_backrefs"," for a picture of the reference chain back to a module-level name. The chain usually ends at something global: a registry, a class attribute, a logging handler list, an ",[14,654,655],{},"lru_cache"," on a method that captures ",[14,658,659],{},"self",[324,661,663,749],{"className":662},[327],[329,664,337,668,337,671,337,674,337,681,337,683,337,686,337,692,337,697,337,701,337,704,337,707,337,710,337,713,337,717,337,720,337,725,337,729,337,732,337,738,337,742,337,746],{"viewBox":514,"role":332,"ariaLabelledBy":665,"xmlns":336},[666,667],"pge-t","pge-d",[339,669,670],{"id":666},"From suspect test to reference chain",[343,672,673],{"id":667},"Four steps lead from a suspect to an explanation. The growth report names a test. Repetition confirms linear growth. A snapshot comparison grouped by traceback shows the allocation site with one new object per run. A backreference graph traces what keeps those objects alive back to a global such as a registry or class attribute.",[347,675,349,676,337],{},[351,677,679],{"id":678,"viewBox":354,"refX":355,"refY":356,"markerWidth":357,"markerHeight":357,"orient":358},"pge-a",[360,680],{"d":362,"fill":363},[365,682],{"x":367,"y":367,"width":368,"height":526,"rx":370,"fill":371},[373,684,685],{"x":375,"y":376,"textAnchor":377,"fontSize":378,"fontWeight":379,"fill":380},"Where, then who",[365,687],{"x":384,"y":688,"width":689,"height":532,"rx":388,"fill":690,"stroke":380,"strokeWidth":691},"78","170","#f4f1de","1.5",[373,693,696],{"x":694,"y":695,"textAnchor":377,"fontSize":396,"fontWeight":379,"fill":380},"105","106","growth report",[373,698,700],{"x":694,"y":699,"textAnchor":377,"fontSize":401,"fill":380},"126","names a test",[365,702],{"x":703,"y":688,"width":689,"height":532,"rx":388,"fill":389,"stroke":390,"strokeWidth":391},"215",[373,705,507],{"x":706,"y":695,"textAnchor":377,"fontSize":396,"fontWeight":379,"fill":380},"300",[373,708,709],{"x":706,"y":699,"textAnchor":377,"fontSize":401,"fill":380},"linear growth",[365,711],{"x":712,"y":688,"width":689,"height":532,"rx":388,"fill":425,"stroke":363,"strokeWidth":391},"410",[373,714,716],{"x":715,"y":695,"textAnchor":377,"fontSize":396,"fontWeight":379,"fill":380},"495","compare_to",[373,718,719],{"x":715,"y":699,"textAnchor":377,"fontSize":401,"fill":380},"allocation site",[365,721],{"x":722,"y":688,"width":723,"height":532,"rx":388,"fill":724,"stroke":562,"strokeWidth":391},"605","175","#fbe9e3",[373,726,728],{"x":727,"y":695,"textAnchor":377,"fontSize":396,"fontWeight":379,"fill":380},"692","backrefs",[373,730,731],{"x":727,"y":699,"textAnchor":377,"fontSize":401,"fill":380},"the global holding it",[68,733],{"x1":734,"y1":735,"x2":736,"y2":735,"stroke":363,"strokeWidth":438,"markerEnd":737},"192","113","211","url(#pge-a)",[68,739],{"x1":740,"y1":735,"x2":741,"y2":735,"stroke":363,"strokeWidth":438,"markerEnd":737},"387","406",[68,743],{"x1":744,"y1":735,"x2":745,"y2":735,"stroke":363,"strokeWidth":438,"markerEnd":737},"582","601",[373,747,748],{"x":375,"y":450,"textAnchor":377,"fontSize":451,"fill":380},"The chain almost always ends at module-level state.",[454,750,751],{},"tracemalloc answers where the object was created; the garbage collector answers why it is still alive.",[22,753,755],{"id":754},"leaks-that-live-in-the-tests-themselves","Leaks that live in the tests themselves",[10,757,758],{},"A surprising share of per-test growth comes from test code rather than the application. The recurring causes are worth checking first, because they are quick to fix.",[10,760,761,762,765,766,769],{},"Mocks record every call, with arguments, in ",[14,763,764],{},"call_args_list","; a module-level mock that is never reset accumulates every argument passed to it across the whole session, including large payloads. Signal handlers and event listeners registered in a test and never disconnected keep their closures — and everything those closures reference — alive. Logging handlers added to a logger in a test, without removal in teardown, keep their buffers and formatters. And objects stashed on ",[14,767,768],{},"request.config"," or in a module-level list \"for debugging\" live until the session ends.",[10,771,772,773,35,776,779,780,783,784,787,788,791,792,795,796,799],{},"The common fix is to put each of these behind a fixture with teardown. ",[14,774,775],{},"monkeypatch",[14,777,778],{},"mocker"," from pytest-mock, and ",[14,781,782],{},"caplog"," all clean up automatically; hand-rolled patching and registration usually do not. When the growth report points at a test and the application code looks innocent, look at the test's setup first. A useful habit is to grep the suspect module for ",[14,785,786],{},"patch("," calls outside ",[14,789,790],{},"with"," blocks or decorators, ",[14,793,794],{},".connect("," and ",[14,797,798],{},".addHandler("," without matching teardown, and module-level lists or dicts that tests append to; in practice one of those four patterns explains most of the growth that turns out to belong to the tests themselves rather than the product.",[22,801,803],{"id":802},"keeping-it-from-coming-back","Keeping it from coming back",[10,805,806,807,810],{},"A leak found and fixed tends to return in a different form unless something checks for it. Two lightweight guards work well. The first is a scheduled CI job that runs the suite with ",[14,808,809],{},"--memgrowth"," weekly and posts the top of the report somewhere visible; nobody needs to act on it every week, but a new entry near the top is easy to spot. The second is a targeted regression test for each fixed leak: run the operation a few hundred times inside the test, measure traced memory before and after with a warm-up first, and assert that the growth stays below a small bound. That test is cheap, deterministic enough to live in the normal suite, and fails exactly when the specific leak reappears, with the tracemalloc machinery already in place to explain it. Pair it with a comment linking the original investigation, so whoever sees it fail next knows what they are looking at.",[22,812,814],{"id":813},"edge-cases-and-failure-modes","Edge cases and failure modes",[27,816,817,828,837,850,859],{},[30,818,819,823,824,827],{},[820,821,822],"strong",{},"Test order effects."," With random ordering, the first-use cost moves between tests and the report changes every run. Disable randomisation (",[14,825,826],{},"-p no:randomly",") for measurement runs.",[30,829,830,833,834,43],{},[820,831,832],{},"Fixtures with wider scope."," A module-scoped fixture's allocations appear in the first test that uses it. Growth attributed to a test may belong to its fixtures; check with ",[14,835,836],{},"--setup-show",[30,838,839,842,843,846,847,43],{},[820,840,841],{},"xdist."," Each worker measures independently and the summary hook runs per worker. Run memory measurement without ",[14,844,845],{},"-n",", or aggregate results through ",[14,848,849],{},"pytest_testnodedown",[30,851,852,855,856,43],{},[820,853,854],{},"Native growth only."," RSS growth with flat traced memory means C-level retention — an extension cache or a leak in native code. Profile with memray ",[14,857,858],{},"--native",[30,860,861,864],{},[820,862,863],{},"Overhead distorting timing."," tracemalloc slows allocation-heavy tests noticeably. Never combine memory measurement with timing assertions.",[22,866,868],{"id":867},"frequently-asked-questions","Frequently Asked Questions",[10,870,871,874],{},[820,872,873],{},"How do I find which test is leaking memory?","\nMeasure memory before and after each test with an autouse fixture, record the difference, and report the tests with the largest retained growth. Running each suspicious test repeatedly then separates true leaks, which grow every run, from one-off caches that grow once.",[10,876,877,880],{},[820,878,879],{},"Should I use tracemalloc or RSS for per-test measurement?","\ntracemalloc gives precise Python allocation counts and the lines responsible, but misses native memory and adds overhead. RSS includes everything but is noisy because allocators keep freed memory. Use RSS to find suspects cheaply and tracemalloc to explain them.",[10,882,883,886],{},[820,884,885],{},"Why does the first test in a module always show growth?","\nImports, lazily built caches, compiled regexes and fixture setup allocate on first use and are kept for the rest of the run. That is expected; a leak is growth that repeats every time the same test runs.",[22,888,890],{"id":889},"related","Related",[27,892,893,899,906,912],{},[30,894,895,898],{},[48,896,897],{"href":50},"Memory Profiling with tracemalloc"," — how tracemalloc works.",[30,900,901,905],{},[48,902,904],{"href":903},"\u002Fsystematic-debugging-performance-profiling\u002Fmemory-profiling-with-tracemalloc\u002Fcomparing-tracemalloc-snapshots-to-locate-growth\u002F","Comparing tracemalloc Snapshots to Locate Growth"," — explaining a suspect.",[30,907,908,911],{},[48,909,910],{"href":478},"Profiling Memory with memray"," — native allocations and budgets.",[30,913,914,918],{},[48,915,917],{"href":916},"\u002Fsystematic-debugging-performance-profiling\u002Fmemory-profiling-with-tracemalloc\u002Ffinding-reference-cycles-with-gc-and-objgraph\u002F","Finding Reference Cycles with gc and objgraph"," — what keeps objects alive.",[10,920,921,922],{},"← Back to ",[48,923,897],{"href":50},[925,926,927],"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 .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}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":62,"searchDepth":75,"depth":75,"links":929},[930,931,932,933,934,935,936,937,938,939],{"id":24,"depth":75,"text":25},{"id":54,"depth":75,"text":55},{"id":459,"depth":75,"text":460},{"id":485,"depth":75,"text":486},{"id":574,"depth":75,"text":575},{"id":754,"depth":75,"text":755},{"id":802,"depth":75,"text":803},{"id":813,"depth":75,"text":814},{"id":867,"depth":75,"text":868},{"id":889,"depth":75,"text":890},"Find the tests that leak memory across a suite: an autouse tracemalloc fixture, RSS tracking between tests, repeat-run growth checks, and triaging the leaks it reports.","md",{"slug":943,"type":944,"breadcrumb":945,"datePublished":946,"dateModified":946,"faq":947,"howto":954},"catching-per-test-memory-growth-in-pytest","article","Per-test memory growth","2026-09-18",[948,950,952],{"q":873,"a":949},"Measure memory before and after each test with an autouse fixture, record the difference, and report the tests with the largest retained growth. Running each suspicious test repeatedly then separates true leaks, which grow every run, from one-off caches that grow once.",{"q":879,"a":951},"tracemalloc gives precise Python allocation counts and the lines responsible, but misses native memory and adds overhead. RSS includes everything but is noisy because allocators keep freed memory. Use RSS to find suspects cheaply and tracemalloc to explain them.",{"q":885,"a":953},"Imports, lazily built caches, compiled regexes and fixture setup allocate on first use and are kept for the rest of the run. That is expected; a leak is growth that repeats every time the same test runs.",{"name":955,"description":956,"steps":957},"How to catch per-test memory growth","Measure retained memory around each test, rank the growth, and confirm leaks by repetition.",[958,961,964,967],{"name":959,"text":960},"Add a measuring fixture","Record traced memory or RSS before and after each test in an autouse fixture.",{"name":962,"text":963},"Force collection before measuring","Call gc.collect() so cyclic garbage does not count as growth.",{"name":965,"text":966},"Report the top growers","Print the largest per-test deltas in a terminal summary hook.",{"name":968,"text":969},"Confirm with repetition","Run the suspect test many times with pytest-repeat and check that growth is linear.","\u002Fsystematic-debugging-performance-profiling\u002Fmemory-profiling-with-tracemalloc\u002Fcatching-per-test-memory-growth-in-pytest",{"title":5,"description":940},"systematic-debugging-performance-profiling\u002Fmemory-profiling-with-tracemalloc\u002Fcatching-per-test-memory-growth-in-pytest\u002Findex","rJz-8Rf98x7rp99DJLtLBL8IT865XJHkL44zGXH9lfk",1789718767672]