[{"data":1,"prerenderedAt":1461},["ShallowReactive",2],{"doc:\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Fprofile-slow-pyqgis-code":3},{"id":4,"title":5,"body":6,"description":1451,"extension":1452,"meta":1453,"navigation":214,"path":1457,"seo":1458,"stem":1459,"__hash__":1460},"docs\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Fprofile-slow-pyqgis-code\u002Findex.md","Profile Slow PyQGIS Code",{"type":7,"value":8,"toc":1438},"minimark",[9,13,22,35,143,148,168,172,387,410,418,422,439,446,462,466,471,670,683,808,812,815,879,911,915,918,1105,1118,1125,1204,1208,1219,1283,1293,1297,1340,1344,1350,1354,1360,1369,1383,1389,1399,1403,1434],[10,11,5],"h1",{"id":12},"profile-slow-pyqgis-code",[14,15,16,17,21],"p",{},"Everyone has a theory about which line is slow. The theory is usually wrong, and acting on it produces a rewritten loop that is no faster and slightly harder to read. Profiling takes about a minute and replaces the theory with a ranked list, which is nearly always dominated by one call nobody suspected — a geometry conversion inside an inner loop, a layer lookup by name repeated per feature, a ",[18,19,20],"code",{},"commitChanges()"," where there should have been one at the end.",[14,23,24,25,30,31,34],{},"This recipe belongs to ",[26,27,29],"a",{"href":28},"\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002F","Background Tasks and Plugin Performance",". It covers running ",[18,32,33],{},"cProfile"," inside QGIS, reading its output, timing regions when a full profile is too coarse, and the four patterns that account for most PyQGIS slowness.",[14,36,37],{},[38,39,44,48,52,59,68,77,84,89,93,98,102,108,113,121,129,135,139],"svg",{"viewBox":40,"role":41,"ariaLabel":42,"xmlns":43},"0 0 760 268","img","Profile output of a slow script showing that the loop body people suspect takes a small share of the time while a repeated layer lookup and a geometry conversion dominate","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[45,46,47],"title",{},"Where the time actually went",[49,50,51],"desc",{},"A horizontal bar breakdown of one profiled run. A repeated layer lookup by name takes just over half the total time, a geometry conversion inside the loop takes a further quarter, feature iteration takes a small share, and the arithmetic everyone assumed was the problem is barely visible.",[53,54],"rect",{"x":55,"y":55,"width":56,"height":57,"fill":58},"0","760","268","#f6f3ea",[60,61,67],"text",{"x":62,"y":63,"style":64,"fill":65,"textAnchor":66},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","A real profile: the suspect was innocent",[53,69],{"x":70,"y":71,"width":62,"height":72,"rx":73,"fill":74,"fillOpacity":75,"stroke":74,"style":76},"40","56","36","5","#b91c1c",0.32,"stroke-width:2",[60,78,83],{"x":79,"y":80,"style":81,"fill":82,"textAnchor":66},"230","79","text-anchor:middle;font-size:11px;font-family:sans-serif","#2f3b35","mapLayersByName() called per feature — 52%",[53,85],{"x":86,"y":71,"width":87,"height":72,"rx":73,"fill":88,"fillOpacity":75,"stroke":88,"style":76},"424","184","#b45309",[60,90,92],{"x":91,"y":80,"style":81,"fill":82,"textAnchor":66},"516","asWkt()\u002FfromWkt() — 25%",[53,94],{"x":95,"y":71,"width":96,"height":72,"rx":73,"fill":97,"fillOpacity":75,"stroke":97,"style":76},"612","88","#2563eb",[60,99,101],{"x":100,"y":80,"style":81,"fill":82,"textAnchor":66},"656","iteration",[53,103],{"x":104,"y":71,"width":105,"height":72,"rx":73,"fill":106,"fillOpacity":107,"stroke":106,"style":76},"704","24","#15803d",0.4,[60,109,112],{"x":110,"y":111,"style":81,"fill":106,"textAnchor":66},"640","118","the arithmetic — 2%",[114,115],"line",{"x1":116,"y1":117,"x2":118,"y2":119,"stroke":106,"style":120},"716","96","700","110","stroke-width:1.5",[53,122],{"x":70,"y":123,"width":124,"height":125,"rx":126,"fill":127,"stroke":128,"style":76},"150","688","86","10","#eef7f4","#0f766e",[60,130,134],{"x":131,"y":132,"style":133,"fill":128,"textAnchor":66},"384","176","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","the fix, in both cases, was to hoist a call out of the loop",[60,136,138],{"x":131,"y":137,"style":81,"fill":82,"textAnchor":66},"200","look the layer up once before iterating; keep geometry as QgsGeometry rather than round-tripping through text",[60,140,142],{"x":131,"y":141,"style":81,"fill":82,"textAnchor":66},"222","total run time fell from 41 s to 5 s without changing a single algorithm",[144,145,147],"h2",{"id":146},"prerequisites","Prerequisites",[149,150,151,162,165],"ul",{},[152,153,154,158,159,161],"li",{},[155,156,157],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer — ",[18,160,33],{}," is part of the standard library, so nothing needs installing.",[152,163,164],{},"A reproducible slow operation and a dataset big enough that it takes at least a few seconds.",[152,166,167],{},"Somewhere to write a stats file if you want to inspect the results outside QGIS.",[144,169,171],{"id":170},"profile-a-function-inside-qgis","Profile a function inside QGIS",[173,174,179],"pre",{"className":175,"code":176,"language":177,"meta":178,"style":178},"language-python shiki shiki-themes github-dark","import cProfile\nimport pstats\nimport io\n\ndef slow_operation():\n    layer = QgsProject.instance().mapLayersByName(\"parcels\")[0]\n    total = 0.0\n    for feature in layer.getFeatures():\n        total += feature.geometry().area()\n    return total\n\nprofiler = cProfile.Profile()\nprofiler.enable()\nslow_operation()\nprofiler.disable()\n\nstream = io.StringIO()\npstats.Stats(profiler, stream=stream).sort_stats(\"cumulative\").print_stats(20)\nprint(stream.getvalue())\n","python","",[18,180,181,193,201,209,216,229,254,265,280,292,301,306,317,323,329,335,340,351,378],{"__ignoreMap":178},[182,183,185,189],"span",{"class":114,"line":184},1,[182,186,188],{"class":187},"snl16","import",[182,190,192],{"class":191},"s95oV"," cProfile\n",[182,194,196,198],{"class":114,"line":195},2,[182,197,188],{"class":187},[182,199,200],{"class":191}," pstats\n",[182,202,204,206],{"class":114,"line":203},3,[182,205,188],{"class":187},[182,207,208],{"class":191}," io\n",[182,210,212],{"class":114,"line":211},4,[182,213,215],{"emptyLinePlaceholder":214},true,"\n",[182,217,219,222,226],{"class":114,"line":218},5,[182,220,221],{"class":187},"def",[182,223,225],{"class":224},"svObZ"," slow_operation",[182,227,228],{"class":191},"():\n",[182,230,232,235,238,241,245,248,251],{"class":114,"line":231},6,[182,233,234],{"class":191},"    layer ",[182,236,237],{"class":187},"=",[182,239,240],{"class":191}," QgsProject.instance().mapLayersByName(",[182,242,244],{"class":243},"sU2Wk","\"parcels\"",[182,246,247],{"class":191},")[",[182,249,55],{"class":250},"sDLfK",[182,252,253],{"class":191},"]\n",[182,255,257,260,262],{"class":114,"line":256},7,[182,258,259],{"class":191},"    total ",[182,261,237],{"class":187},[182,263,264],{"class":250}," 0.0\n",[182,266,268,271,274,277],{"class":114,"line":267},8,[182,269,270],{"class":187},"    for",[182,272,273],{"class":191}," feature ",[182,275,276],{"class":187},"in",[182,278,279],{"class":191}," layer.getFeatures():\n",[182,281,283,286,289],{"class":114,"line":282},9,[182,284,285],{"class":191},"        total ",[182,287,288],{"class":187},"+=",[182,290,291],{"class":191}," feature.geometry().area()\n",[182,293,295,298],{"class":114,"line":294},10,[182,296,297],{"class":187},"    return",[182,299,300],{"class":191}," total\n",[182,302,304],{"class":114,"line":303},11,[182,305,215],{"emptyLinePlaceholder":214},[182,307,309,312,314],{"class":114,"line":308},12,[182,310,311],{"class":191},"profiler ",[182,313,237],{"class":187},[182,315,316],{"class":191}," cProfile.Profile()\n",[182,318,320],{"class":114,"line":319},13,[182,321,322],{"class":191},"profiler.enable()\n",[182,324,326],{"class":114,"line":325},14,[182,327,328],{"class":191},"slow_operation()\n",[182,330,332],{"class":114,"line":331},15,[182,333,334],{"class":191},"profiler.disable()\n",[182,336,338],{"class":114,"line":337},16,[182,339,215],{"emptyLinePlaceholder":214},[182,341,343,346,348],{"class":114,"line":342},17,[182,344,345],{"class":191},"stream ",[182,347,237],{"class":187},[182,349,350],{"class":191}," io.StringIO()\n",[182,352,354,357,361,363,366,369,372,375],{"class":114,"line":353},18,[182,355,356],{"class":191},"pstats.Stats(profiler, ",[182,358,360],{"class":359},"s9osk","stream",[182,362,237],{"class":187},[182,364,365],{"class":191},"stream).sort_stats(",[182,367,368],{"class":243},"\"cumulative\"",[182,370,371],{"class":191},").print_stats(",[182,373,374],{"class":250},"20",[182,376,377],{"class":191},")\n",[182,379,381,384],{"class":114,"line":380},19,[182,382,383],{"class":250},"print",[182,385,386],{"class":191},"(stream.getvalue())\n",[14,388,389,392,393,396,397,400,401,405,406,409],{},[155,390,391],{},"Breakdown:"," Wrapping the call rather than using ",[18,394,395],{},"cProfile.run()"," with a string keeps the code normal and debuggable. Sorting by ",[18,398,399],{},"cumulative"," puts the callers at the top — the functions ",[402,403,404],"em",{},"inside which"," most time is spent — which is how you find the loop that is calling something expensive. Sorting by ",[18,407,408],{},"tottime"," instead puts the leaves at the top: the functions doing the actual work, excluding what they call. Read both: cumulative tells you where to look, total tells you what to fix. Limiting to twenty lines keeps the output readable; the tail is almost always noise.",[14,411,412,413,417],{},"Profiling in the Python console works exactly the same way, which makes it the fastest place to iterate — see ",[26,414,416],{"href":415},"\u002Fpyqgis-fundamentals-environment-setup\u002Fqgis-python-console-basics\u002F","QGIS Python Console Basics",".",[144,419,421],{"id":420},"read-the-output","Read the output",[14,423,424,425,428,429,431,432,435,436,438],{},"The columns that matter are ",[18,426,427],{},"ncalls",", ",[18,430,408],{}," and ",[18,433,434],{},"cumtime",". A line with a huge ",[18,437,427],{}," and a small per-call time is the classic PyQGIS problem: something cheap being done far too often.",[173,440,444],{"className":441,"code":443,"language":60,"meta":178},[442],"language-text","   ncalls  tottime  percall  cumtime  percall filename:lineno(function)\n   200000    9.412    0.000   21.318    0.000 qgis\u002Fcore.py:1(mapLayersByName)\n   200000    5.103    0.000    5.103    0.000 {method 'area' of 'QgsGeometry'}\n        1    0.004    0.004   41.220   41.220 slow.py:4(slow_operation)\n",[18,445,443],{"__ignoreMap":178},[14,447,448,450,451,454,455,458,459,461],{},[155,449,391],{}," Two hundred thousand calls to a layer lookup means it is inside the loop — a lookup that should happen once is happening once per feature, and moving one line above the ",[18,452,453],{},"for"," removes half the run time. ",[18,456,457],{},"area()"," called two hundred thousand times is legitimate, and its cost is the honest price of the calculation. The one-call entry with an enormous ",[18,460,434],{}," is simply the entry point, and is always at or near the top when sorting cumulatively; it tells you nothing except that you profiled the right function.",[144,463,465],{"id":464},"time-regions-when-a-profile-is-too-coarse","Time regions when a profile is too coarse",[14,467,468,470],{},[18,469,33],{}," reports per function. When one function contains three phases and you need to know which, time the regions directly.",[173,472,474],{"className":175,"code":473,"language":177,"meta":178,"style":178},"import time\nfrom contextlib import contextmanager\n\n@contextmanager\ndef timed(label):\n    started = time.perf_counter()\n    try:\n        yield\n    finally:\n        print(f\"{label}: {time.perf_counter() - started:.2f}s\")\n\nwith timed(\"read\"):\n    features = list(layer.getFeatures(request))\nwith timed(\"compute\"):\n    results = [compute(f) for f in features]\nwith timed(\"write\"):\n    provider.addFeatures(build(results))\n",[18,475,476,483,496,500,505,515,525,533,538,545,592,596,610,623,634,654,665],{"__ignoreMap":178},[182,477,478,480],{"class":114,"line":184},[182,479,188],{"class":187},[182,481,482],{"class":191}," time\n",[182,484,485,488,491,493],{"class":114,"line":195},[182,486,487],{"class":187},"from",[182,489,490],{"class":191}," contextlib ",[182,492,188],{"class":187},[182,494,495],{"class":191}," contextmanager\n",[182,497,498],{"class":114,"line":203},[182,499,215],{"emptyLinePlaceholder":214},[182,501,502],{"class":114,"line":211},[182,503,504],{"class":224},"@contextmanager\n",[182,506,507,509,512],{"class":114,"line":218},[182,508,221],{"class":187},[182,510,511],{"class":224}," timed",[182,513,514],{"class":191},"(label):\n",[182,516,517,520,522],{"class":114,"line":231},[182,518,519],{"class":191},"    started ",[182,521,237],{"class":187},[182,523,524],{"class":191}," time.perf_counter()\n",[182,526,527,530],{"class":114,"line":256},[182,528,529],{"class":187},"    try",[182,531,532],{"class":191},":\n",[182,534,535],{"class":114,"line":267},[182,536,537],{"class":187},"        yield\n",[182,539,540,543],{"class":114,"line":282},[182,541,542],{"class":187},"    finally",[182,544,532],{"class":191},[182,546,547,550,553,556,559,562,565,568,571,573,576,579,582,585,587,590],{"class":114,"line":294},[182,548,549],{"class":250},"        print",[182,551,552],{"class":191},"(",[182,554,555],{"class":187},"f",[182,557,558],{"class":243},"\"",[182,560,561],{"class":250},"{",[182,563,564],{"class":191},"label",[182,566,567],{"class":250},"}",[182,569,570],{"class":243},": ",[182,572,561],{"class":250},[182,574,575],{"class":191},"time.perf_counter() ",[182,577,578],{"class":187},"-",[182,580,581],{"class":191}," started",[182,583,584],{"class":187},":.2f",[182,586,567],{"class":250},[182,588,589],{"class":243},"s\"",[182,591,377],{"class":191},[182,593,594],{"class":114,"line":303},[182,595,215],{"emptyLinePlaceholder":214},[182,597,598,601,604,607],{"class":114,"line":308},[182,599,600],{"class":187},"with",[182,602,603],{"class":191}," timed(",[182,605,606],{"class":243},"\"read\"",[182,608,609],{"class":191},"):\n",[182,611,612,615,617,620],{"class":114,"line":319},[182,613,614],{"class":191},"    features ",[182,616,237],{"class":187},[182,618,619],{"class":250}," list",[182,621,622],{"class":191},"(layer.getFeatures(request))\n",[182,624,625,627,629,632],{"class":114,"line":325},[182,626,600],{"class":187},[182,628,603],{"class":191},[182,630,631],{"class":243},"\"compute\"",[182,633,609],{"class":191},[182,635,636,639,641,644,646,649,651],{"class":114,"line":331},[182,637,638],{"class":191},"    results ",[182,640,237],{"class":187},[182,642,643],{"class":191}," [compute(f) ",[182,645,453],{"class":187},[182,647,648],{"class":191}," f ",[182,650,276],{"class":187},[182,652,653],{"class":191}," features]\n",[182,655,656,658,660,663],{"class":114,"line":337},[182,657,600],{"class":187},[182,659,603],{"class":191},[182,661,662],{"class":243},"\"write\"",[182,664,609],{"class":191},[182,666,667],{"class":114,"line":342},[182,668,669],{"class":191},"    provider.addFeatures(build(results))\n",[14,671,672,674,675,678,679,682],{},[155,673,391],{}," A context manager keeps the timing out of the logic, so the instrumentation can stay in the code rather than being added and removed. ",[18,676,677],{},"finally"," means a raised exception still reports the time spent, which is useful when the slow phase is also the failing one. ",[18,680,681],{},"perf_counter()"," is monotonic, so an NTP adjustment mid-run cannot produce a negative duration. Three numbers of this shape usually settle the question immediately: a slow read points at the feature request, a slow compute at the algorithm, a slow write at the transaction boundary.",[14,684,685],{},[38,686,689,692,695,698,702,711,717,721,725,729,733,737,742,745,749,752,756,759,763,765,768,770,773,776,780,782,785,787,790,794,798,800,803,805],{"viewBox":687,"role":41,"ariaLabel":688,"xmlns":43},"0 0 760 262","Four common causes of slow PyQGIS code with the symptom each produces in a profile and the corresponding fix",[45,690,691],{},"Four patterns behind most slow PyQGIS code",[49,693,694],{},"A repeated lookup inside a loop shows as a very high call count on a cheap function and is fixed by hoisting it out. Reading unnecessary attributes or geometry shows as time inside feature iteration and is fixed with a feature request. A nested loop over two layers shows as quadratic growth with data size and is fixed with a spatial index. Committing per feature shows as time inside commitChanges and is fixed by batching.",[53,696],{"x":55,"y":55,"width":56,"height":697,"fill":58},"262",[60,699,701],{"x":62,"y":700,"style":64,"fill":65,"textAnchor":66},"26","Recognise the shape and you already know the fix",[53,703],{"x":704,"y":705,"width":706,"height":707,"rx":708,"fill":709,"stroke":710,"style":120},"16","42","240","30","6","#efeadd","#59645f",[60,712,716],{"x":713,"y":714,"style":715,"fill":65,"textAnchor":66},"136","62","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","what the profile shows",[53,718],{"x":719,"y":705,"width":720,"height":707,"rx":708,"fill":709,"stroke":710,"style":120},"264","228",[60,722,724],{"x":723,"y":714,"style":715,"fill":65,"textAnchor":66},"378","the cause",[53,726],{"x":727,"y":705,"width":728,"height":707,"rx":708,"fill":709,"stroke":710,"style":120},"500","244",[60,730,732],{"x":731,"y":714,"style":715,"fill":65,"textAnchor":66},"622","the fix",[53,734],{"x":704,"y":735,"width":706,"height":70,"rx":708,"fill":736,"stroke":710,"style":120},"78","#fffdf7",[60,738,741],{"x":707,"y":739,"style":740,"fill":82},"103","font-size:11px;font-family:sans-serif","ncalls equals feature count",[53,743],{"x":719,"y":735,"width":720,"height":70,"rx":708,"fill":744,"stroke":88,"style":120},"#fdf2e2",[60,746,748],{"x":747,"y":739,"style":740,"fill":82},"278","a lookup inside the loop",[53,750],{"x":727,"y":735,"width":728,"height":70,"rx":708,"fill":751,"stroke":106,"style":120},"#edf8e9",[60,753,755],{"x":754,"y":739,"style":740,"fill":82},"514","hoist it above the for",[53,757],{"x":704,"y":758,"width":706,"height":70,"rx":708,"fill":736,"stroke":710,"style":120},"124",[60,760,762],{"x":707,"y":761,"style":740,"fill":82},"149","time inside getFeatures",[53,764],{"x":719,"y":758,"width":720,"height":70,"rx":708,"fill":744,"stroke":88,"style":120},[60,766,767],{"x":747,"y":761,"style":740,"fill":82},"reading unused columns",[53,769],{"x":727,"y":758,"width":728,"height":70,"rx":708,"fill":751,"stroke":106,"style":120},[60,771,772],{"x":754,"y":761,"style":740,"fill":82},"QgsFeatureRequest",[53,774],{"x":704,"y":775,"width":706,"height":70,"rx":708,"fill":736,"stroke":710,"style":120},"170",[60,777,779],{"x":707,"y":778,"style":740,"fill":82},"195","time grows with the square",[53,781],{"x":719,"y":775,"width":720,"height":70,"rx":708,"fill":744,"stroke":88,"style":120},[60,783,784],{"x":747,"y":778,"style":740,"fill":82},"nested loop over two layers",[53,786],{"x":727,"y":775,"width":728,"height":70,"rx":708,"fill":751,"stroke":106,"style":120},[60,788,789],{"x":754,"y":778,"style":740,"fill":82},"QgsSpatialIndex",[53,791],{"x":704,"y":792,"width":706,"height":793,"rx":708,"fill":736,"stroke":710,"style":120},"216","34",[60,795,797],{"x":707,"y":796,"style":740,"fill":82},"238","time inside commitChanges",[53,799],{"x":719,"y":792,"width":720,"height":793,"rx":708,"fill":744,"stroke":88,"style":120},[60,801,802],{"x":747,"y":796,"style":740,"fill":82},"committing per feature",[53,804],{"x":727,"y":792,"width":728,"height":793,"rx":708,"fill":751,"stroke":106,"style":120},[60,806,807],{"x":754,"y":796,"style":740,"fill":82},"one commit at the end",[144,809,811],{"id":810},"profile-a-plugin-without-editing-it","Profile a plugin without editing it",[14,813,814],{},"For code you would rather not modify, wrap the entry point from the console:",[173,816,818],{"className":175,"code":817,"language":177,"meta":178,"style":178},"import cProfile\nfrom qgis.utils import plugins\n\nprofiler = cProfile.Profile()\nprofiler.enable()\nplugins[\"my_plugin\"].run()\nprofiler.disable()\nprofiler.dump_stats(\"\u002Ftmp\u002Fmy_plugin.prof\")\n",[18,819,820,826,838,842,850,854,865,869],{"__ignoreMap":178},[182,821,822,824],{"class":114,"line":184},[182,823,188],{"class":187},[182,825,192],{"class":191},[182,827,828,830,833,835],{"class":114,"line":195},[182,829,487],{"class":187},[182,831,832],{"class":191}," qgis.utils ",[182,834,188],{"class":187},[182,836,837],{"class":191}," plugins\n",[182,839,840],{"class":114,"line":203},[182,841,215],{"emptyLinePlaceholder":214},[182,843,844,846,848],{"class":114,"line":211},[182,845,311],{"class":191},[182,847,237],{"class":187},[182,849,316],{"class":191},[182,851,852],{"class":114,"line":218},[182,853,322],{"class":191},[182,855,856,859,862],{"class":114,"line":231},[182,857,858],{"class":191},"plugins[",[182,860,861],{"class":243},"\"my_plugin\"",[182,863,864],{"class":191},"].run()\n",[182,866,867],{"class":114,"line":256},[182,868,334],{"class":191},[182,870,871,874,877],{"class":114,"line":267},[182,872,873],{"class":191},"profiler.dump_stats(",[182,875,876],{"class":243},"\"\u002Ftmp\u002Fmy_plugin.prof\"",[182,878,377],{"class":191},[14,880,881,883,884,887,888,891,892,895,896,899,900,903,904,907,908,910],{},[155,882,391],{}," ",[18,885,886],{},"qgis.utils.plugins"," holds every loaded plugin by its folder name, so its public methods can be called directly from the console — which is also a fast way to test a plugin without clicking through its interface. ",[18,889,890],{},"dump_stats()"," writes a binary profile that external viewers such as ",[18,893,894],{},"snakeviz"," or ",[18,897,898],{},"tuna"," can render as a flame graph, which is far easier to read than a text table when the call tree is deep. Note that anything running on a ",[18,901,902],{},"QgsTask"," will ",[402,905,906],{},"not"," appear: ",[18,909,33],{}," follows the thread that enabled it, so profile the work synchronously first and move it to a task afterwards.",[144,912,914],{"id":913},"keep-a-benchmark-so-an-improvement-stays-improved","Keep a benchmark so an improvement stays improved",[14,916,917],{},"A profile tells you where the time went once. A benchmark tells you whether last month's optimisation survived this month's changes, and it costs about fifteen lines.",[173,919,921],{"className":175,"code":920,"language":177,"meta":178,"style":178},"import time\nimport statistics\n\ndef benchmark(operation, runs=5, warmup=1):\n    for _ in range(warmup):\n        operation()                       # fill the OS file cache\n    timings = []\n    for _ in range(runs):\n        started = time.perf_counter()\n        operation()\n        timings.append(time.perf_counter() - started)\n    return {\n        \"best\": min(timings),\n        \"median\": statistics.median(timings),\n        \"spread\": max(timings) - min(timings),\n    }\n\nprint(benchmark(lambda: summarise_areas(layer)))\n",[18,922,923,929,936,940,964,979,988,998,1011,1020,1025,1035,1042,1055,1063,1083,1088,1092],{"__ignoreMap":178},[182,924,925,927],{"class":114,"line":184},[182,926,188],{"class":187},[182,928,482],{"class":191},[182,930,931,933],{"class":114,"line":195},[182,932,188],{"class":187},[182,934,935],{"class":191}," statistics\n",[182,937,938],{"class":114,"line":203},[182,939,215],{"emptyLinePlaceholder":214},[182,941,942,944,947,950,952,954,957,959,962],{"class":114,"line":211},[182,943,221],{"class":187},[182,945,946],{"class":224}," benchmark",[182,948,949],{"class":191},"(operation, runs",[182,951,237],{"class":187},[182,953,73],{"class":250},[182,955,956],{"class":191},", warmup",[182,958,237],{"class":187},[182,960,961],{"class":250},"1",[182,963,609],{"class":191},[182,965,966,968,971,973,976],{"class":114,"line":218},[182,967,270],{"class":187},[182,969,970],{"class":191}," _ ",[182,972,276],{"class":187},[182,974,975],{"class":250}," range",[182,977,978],{"class":191},"(warmup):\n",[182,980,981,984],{"class":114,"line":231},[182,982,983],{"class":191},"        operation()                       ",[182,985,987],{"class":986},"sjoCn","# fill the OS file cache\n",[182,989,990,993,995],{"class":114,"line":256},[182,991,992],{"class":191},"    timings ",[182,994,237],{"class":187},[182,996,997],{"class":191}," []\n",[182,999,1000,1002,1004,1006,1008],{"class":114,"line":267},[182,1001,270],{"class":187},[182,1003,970],{"class":191},[182,1005,276],{"class":187},[182,1007,975],{"class":250},[182,1009,1010],{"class":191},"(runs):\n",[182,1012,1013,1016,1018],{"class":114,"line":282},[182,1014,1015],{"class":191},"        started ",[182,1017,237],{"class":187},[182,1019,524],{"class":191},[182,1021,1022],{"class":114,"line":294},[182,1023,1024],{"class":191},"        operation()\n",[182,1026,1027,1030,1032],{"class":114,"line":303},[182,1028,1029],{"class":191},"        timings.append(time.perf_counter() ",[182,1031,578],{"class":187},[182,1033,1034],{"class":191}," started)\n",[182,1036,1037,1039],{"class":114,"line":308},[182,1038,297],{"class":187},[182,1040,1041],{"class":191}," {\n",[182,1043,1044,1047,1049,1052],{"class":114,"line":319},[182,1045,1046],{"class":243},"        \"best\"",[182,1048,570],{"class":191},[182,1050,1051],{"class":250},"min",[182,1053,1054],{"class":191},"(timings),\n",[182,1056,1057,1060],{"class":114,"line":325},[182,1058,1059],{"class":243},"        \"median\"",[182,1061,1062],{"class":191},": statistics.median(timings),\n",[182,1064,1065,1068,1070,1073,1076,1078,1081],{"class":114,"line":331},[182,1066,1067],{"class":243},"        \"spread\"",[182,1069,570],{"class":191},[182,1071,1072],{"class":250},"max",[182,1074,1075],{"class":191},"(timings) ",[182,1077,578],{"class":187},[182,1079,1080],{"class":250}," min",[182,1082,1054],{"class":191},[182,1084,1085],{"class":114,"line":337},[182,1086,1087],{"class":191},"    }\n",[182,1089,1090],{"class":114,"line":342},[182,1091,215],{"emptyLinePlaceholder":214},[182,1093,1094,1096,1099,1102],{"class":114,"line":353},[182,1095,383],{"class":250},[182,1097,1098],{"class":191},"(benchmark(",[182,1100,1101],{"class":187},"lambda",[182,1103,1104],{"class":191},": summarise_areas(layer)))\n",[14,1106,1107,1109,1110,1113,1114,1117],{},[155,1108,391],{}," The warm-up run exists so the measurement reflects your code rather than the first read of a file from disk — comparing a cold run against a warm one is the single easiest way to convince yourself of an improvement that is not there. Reporting the ",[155,1111,1112],{},"median"," rather than the mean keeps one unlucky run from dominating, and the ",[155,1115,1116],{},"best"," time is the closest thing to the operation's true cost. The spread is the honest part: when it is large relative to the median, the machine is noisy and any difference smaller than the spread is not a result.",[14,1119,1120,1121,417],{},"Record the numbers with the dataset they came from, because a benchmark without its input is meaningless. Where the operation is important enough, wire it into the test suite with a generous ceiling — assert that summarising ten thousand features takes under two seconds rather than asserting an exact time — so a regression that makes it ten times slower fails the build while ordinary variation does not. That fits naturally into the CI setup described in ",[26,1122,1124],{"href":1123},"\u002Fqgis-plugin-development\u002Ftesting-and-ci-for-plugins\u002Frun-qgis-plugin-tests-in-github-actions\u002F","Run QGIS Plugin Tests in GitHub Actions",[14,1126,1127],{},[38,1128,1131,1134,1137,1139,1142,1147,1153,1160,1166,1169,1173,1178,1183,1188,1193,1197,1200],{"viewBox":1129,"role":41,"ariaLabel":1130,"xmlns":43},"0 0 760 240","Five timed runs after a warm-up, with the first cold run shown separately as far slower and excluded from the median",[45,1132,1133],{},"Why the first run is discarded",[49,1135,1136],{},"A cold first run takes far longer than the rest because the file cache is empty. Five subsequent runs cluster closely, and their median is reported. Including the cold run in the average would make any later comparison look like an improvement that never happened.",[53,1138],{"x":55,"y":55,"width":56,"height":706,"fill":58},[60,1140,1141],{"x":62,"y":63,"style":64,"fill":65,"textAnchor":66},"Measure warm runs, or measure your disk",[114,1143],{"x1":1144,"y1":1145,"x2":1146,"y2":1145,"stroke":710,"style":120},"120","196","720",[60,1148,1152],{"x":1149,"y":137,"style":1150,"fill":710,"textAnchor":1151},"100","text-anchor:end;font-size:10px;font-family:sans-serif","end","0 s",[53,1154],{"x":1155,"y":1156,"width":1157,"height":758,"rx":1158,"fill":88,"fillOpacity":1159,"stroke":88,"style":76},"140","72","70","4",0.3,[60,1161,1165],{"x":1162,"y":1163,"style":1164,"fill":88,"textAnchor":66},"175","64","text-anchor:middle;font-size:10px;font-family:sans-serif","cold",[53,1167],{"x":1168,"y":1155,"width":1157,"height":71,"rx":1158,"fill":106,"fillOpacity":1159,"stroke":106,"style":76},"252",[53,1170],{"x":1171,"y":1172,"width":1157,"height":714,"rx":1158,"fill":106,"fillOpacity":1159,"stroke":106,"style":76},"336","134",[53,1174],{"x":1175,"y":1176,"width":1157,"height":1177,"rx":1158,"fill":106,"fillOpacity":1159,"stroke":106,"style":76},"420","142","54",[53,1179],{"x":1180,"y":1181,"width":1157,"height":1182,"rx":1158,"fill":106,"fillOpacity":1159,"stroke":106,"style":76},"504","138","58",[53,1184],{"x":1185,"y":1186,"width":1157,"height":1187,"rx":1158,"fill":106,"fillOpacity":1159,"stroke":106,"style":76},"588","144","52",[114,1189],{"x1":1168,"y1":1190,"x2":1191,"y2":1190,"stroke":128,"style":1192},"139","658","stroke-width:2;stroke-dasharray:5 4",[60,1194,1112],{"x":1195,"y":713,"style":1196,"fill":128},"690","font-size:10px;font-family:sans-serif",[60,1198,1199],{"x":1162,"y":792,"style":1164,"fill":710,"textAnchor":66},"discarded",[60,1201,1203],{"x":1202,"y":792,"style":1164,"fill":710,"textAnchor":66},"455","five timed runs, tightly clustered",[144,1205,1207],{"id":1206},"qgis-version-compatibility","QGIS version compatibility",[14,1209,1210,428,1212,431,1215,1218],{},[18,1211,33],{},[18,1213,1214],{},"pstats",[18,1216,1217],{},"time.perf_counter()"," are standard library and behave identically on every QGIS 3.x release.",[1220,1221,1222,1238],"table",{},[1223,1224,1225],"thead",{},[1226,1227,1228,1232,1235],"tr",{},[1229,1230,1231],"th",{},"QGIS version",[1229,1233,1234],{},"Python",[1229,1236,1237],{},"Notes",[1239,1240,1241,1253,1262,1273],"tbody",{},[1226,1242,1243,1247,1250],{},[1244,1245,1246],"td",{},"3.22 LTR",[1244,1248,1249],{},"3.9",[1244,1251,1252],{},"Identical.",[1226,1254,1255,1258,1260],{},[1244,1256,1257],{},"3.28 LTR",[1244,1259,1249],{},[1244,1261,1252],{},[1226,1263,1264,1267,1270],{},[1244,1265,1266],{},"3.34 LTR",[1244,1268,1269],{},"3.12",[1244,1271,1272],{},"Baseline for this page.",[1226,1274,1275,1278,1280],{},[1244,1276,1277],{},"3.40 \u002F 3.44",[1244,1279,1269],{},[1244,1281,1282],{},"Identical; the built-in Debugging\u002FDevelopment Tools panel also reports render and query timings.",[14,1284,1285,1286,1289,1290,1292],{},"QGIS's own ",[155,1287,1288],{},"Debugging and Development Tools"," panel is the complement to this page: it profiles rendering and provider queries, which ",[18,1291,33],{}," cannot see because they happen in C++ and on other threads.",[144,1294,1296],{"id":1295},"troubleshooting","Troubleshooting",[149,1298,1299,1308,1314,1320,1326,1334],{},[152,1300,1301,1307],{},[155,1302,1303,1304,417],{},"The profile is dominated by ",[18,1305,1306],{},"{built-in method builtins.exec}"," You profiled the console's execution wrapper. Profile a function, not a pasted block.",[152,1309,1310,1313],{},[155,1311,1312],{},"Nothing appears for the slow part."," It runs on another thread or inside C++. Time the region instead, or use the Debugging and Development Tools panel.",[152,1315,1316,1319],{},[155,1317,1318],{},"Timings vary by a factor of two between runs."," The file cache is cold on the first run. Warm it and compare second runs.",[152,1321,1322,1325],{},[155,1323,1324],{},"The profiled run is much slower than the real one."," Profiling overhead is real, roughly a factor of two on call-heavy code. Compare profiled runs with each other, and confirm improvements with a plain timer.",[152,1327,1328,1333],{},[155,1329,1330,1332],{},[18,1331,427],{}," shows two numbers separated by a slash."," The function is recursive; the second number is the primitive call count.",[152,1335,1336,1339],{},[155,1337,1338],{},"The fix made no difference."," The function you optimised was not on the critical path. Re-profile rather than continuing on the same theory.",[144,1341,1343],{"id":1342},"conclusion","Conclusion",[14,1345,1346,1347,1349],{},"Profile before optimising: wrap the call in ",[18,1348,33],{},", sort by cumulative to find where the time is spent and by total to find what is spending it, and look first for a high call count on something cheap. Four patterns cover most cases — a lookup in a loop, unnecessary columns, a nested loop that wants an index, and a per-feature commit — and all four are visible in the first twenty lines of output.",[144,1351,1353],{"id":1352},"frequently-asked-questions","Frequently Asked Questions",[14,1355,1356,1359],{},[155,1357,1358],{},"Does profiling work in the QGIS Python console?","\nYes, exactly as in a script. It is usually the quickest place to work, because you can re-run the profiled function without restarting anything.",[14,1361,1362,1365,1366,1368],{},[155,1363,1364],{},"How do I profile code inside a QgsTask?","\nYou cannot directly — ",[18,1367,33],{}," follows one thread. Extract the work into a plain function, profile it synchronously against the same data, then run the optimised version on the task.",[14,1370,1371,1374,1377,1378,1382],{},[155,1372,1373],{},"Is there a line profiler for PyQGIS?",[18,1375,1376],{},"line_profiler"," works if you install it into the QGIS Python environment, which the virtual-environment setup in ",[26,1379,1381],{"href":1380},"\u002Fpyqgis-fundamentals-environment-setup\u002Fvirtual-environments-for-gis\u002F","Virtual Environments for GIS"," makes straightforward. For most problems function-level granularity is enough.",[14,1384,1385,1388],{},[155,1386,1387],{},"Why is a Processing algorithm showing as one fast call?","\nBecause it is: the work happens in compiled code the profiler cannot see. Time it as a region instead, and pass a feedback object if you want progress from inside.",[14,1390,1391,1394,1395,1398],{},[155,1392,1393],{},"Should I optimise memory as well as time?","\nOnly when it is the constraint. ",[18,1396,1397],{},"tracemalloc"," will show where allocations come from, and the usual answer in PyQGIS is a list of features that should have been a generator.",[144,1400,1402],{"id":1401},"related","Related",[149,1404,1405,1410,1416,1422,1428],{},[152,1406,1407,1409],{},[26,1408,29],{"href":28}," — the guide this recipe belongs to",[152,1411,1412],{},[26,1413,1415],{"href":1414},"\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Fspeed-up-feature-iteration-with-qgsfeaturerequest-pyqgis\u002F","Speed Up Feature Iteration with QgsFeatureRequest",[152,1417,1418],{},[26,1419,1421],{"href":1420},"\u002Fpyqgis-fundamentals-environment-setup\u002Fdebugging-pyqgis-scripts\u002F","Debugging PyQGIS Scripts",[152,1423,1424],{},[26,1425,1427],{"href":1426},"\u002Fpyqgis-fundamentals-environment-setup\u002Fdebugging-pyqgis-scripts\u002Flog-messages-to-qgis-message-log-pyqgis\u002F","Log Messages to the QGIS Message Log in PyQGIS",[152,1429,1430],{},[26,1431,1433],{"href":1432},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuild-spatial-index-pyqgis\u002F","Build a Spatial Index in PyQGIS",[1435,1436,1437],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}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 pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}",{"title":178,"searchDepth":195,"depth":195,"links":1439},[1440,1441,1442,1443,1444,1445,1446,1447,1448,1449,1450],{"id":146,"depth":195,"text":147},{"id":170,"depth":195,"text":171},{"id":420,"depth":195,"text":421},{"id":464,"depth":195,"text":465},{"id":810,"depth":195,"text":811},{"id":913,"depth":195,"text":914},{"id":1206,"depth":195,"text":1207},{"id":1295,"depth":195,"text":1296},{"id":1342,"depth":195,"text":1343},{"id":1352,"depth":195,"text":1353},{"id":1401,"depth":195,"text":1402},"Find the line that is actually slow — run cProfile inside QGIS, read cumulative against total time, time individual blocks, and recognise the four patterns that cause most PyQGIS slowness.","md",{"slug":12,"type":1454,"breadcrumb":1455,"datePublished":1456,"dateModified":1456},"article","Profile Slow Code","2026-08-10","\u002Fqgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Fprofile-slow-pyqgis-code",{"title":5,"description":1451},"qgis-plugin-development\u002Fbackground-tasks-and-plugin-performance\u002Fprofile-slow-pyqgis-code\u002Findex","vOF9plhd_ZLcDaQFUn5x3N2DvwmD_NR7tW9iGGRFKNM",1786401337553]