[{"data":1,"prerenderedAt":1484},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuild-spatial-index-pyqgis":3},{"id":4,"title":5,"body":6,"description":1473,"extension":1474,"meta":1475,"navigation":259,"path":1480,"seo":1481,"stem":1482,"__hash__":1483},"docs\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuild-spatial-index-pyqgis\u002Findex.md","Build and Use a Spatial Index in PyQGIS",{"type":7,"value":8,"toc":1460},"minimark",[9,13,22,31,194,199,220,224,227,323,337,340,382,399,403,418,636,653,657,663,804,829,833,836,943,1019,1034,1038,1041,1132,1135,1200,1215,1222,1226,1232,1293,1306,1310,1367,1371,1377,1381,1396,1405,1411,1422,1428,1432,1456],[10,11,5],"h1",{"id":12},"build-and-use-a-spatial-index-in-pyqgis",[14,15,16,17,21],"p",{},"Every spatial join written as a nested loop is quadratic. Two layers of ten thousand features means a hundred million exact predicate evaluations, each of which is real geometric work — the script does not crash, it simply appears to hang. A ",[18,19,20],"code",{},"QgsSpatialIndex"," is an R-tree over the features' bounding boxes, and it turns that hundred million into ten thousand cheap box lookups followed by a handful of exact tests each.",[14,23,24,25,30],{},"This page is a focused recipe within ",[26,27,29],"a",{"href":28},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002F","Geometry Operations and Spatial Predicates in PyQGIS",". It covers building an index, the two query methods, the memory trade-off of storing geometries inside it, and keeping the index correct when the underlying layer changes.",[14,32,33],{},[34,35,40,44,48,55,72,81,90,96,104,110,116,120,124,127,135,140,160,163,178,181,188],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 276","img","An R-tree structure over eight features, with a root node containing two branch nodes whose bounding boxes each enclose four leaf features, and a query box descending only the branch it overlaps","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"How an R-tree narrows the search",[45,46,47],"desc",{},"Eight features are grouped into two bounding boxes. A query rectangle overlaps only the left group's box, so the search descends into that branch and tests its four leaves, skipping the right branch and its four features entirely without examining them.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","276","#f6f3ea",[56,57,58],"defs",{},[59,60,67],"marker",{"id":61,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"idxTreeArrow","0 0 10 10","8","5","7","auto-start-reverse",[68,69],"path",{"d":70,"fill":71},"M0 0 L10 5 L0 10 z","#59645f",[73,74,80],"text",{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"380","26","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","A branch whose box misses the query is skipped whole",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":63,"fill":87,"stroke":88,"style":89},"308","44","144","40","#26322d","#0f766e","stroke-width:2.5",[73,91,95],{"x":75,"y":92,"style":93,"fill":94,"textAnchor":79},"69","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","#d9f99d","root node",[49,97],{"x":98,"y":99,"width":100,"height":101,"rx":63,"fill":102,"stroke":103,"style":89},"120","118","180","42","#fffdf7","#15803d",[73,105,109],{"x":106,"y":107,"style":108,"fill":103,"textAnchor":79},"210","137","text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","branch A",[73,111,115],{"x":106,"y":112,"style":113,"fill":114,"textAnchor":79},"153","text-anchor:middle;font-size:11px;font-family:sans-serif","#2f3b35","box overlaps the query",[49,117],{"x":118,"y":99,"width":100,"height":101,"rx":63,"fill":102,"stroke":71,"style":119},"460","stroke-width:2;stroke-dasharray:5 4",[73,121,123],{"x":122,"y":107,"style":108,"fill":71,"textAnchor":79},"550","branch B",[73,125,126],{"x":122,"y":112,"style":113,"fill":114,"textAnchor":79},"no overlap — skipped",[128,129],"line",{"x1":130,"y1":131,"x2":132,"y2":133,"stroke":103,"style":134},"344","84","240","114","stroke-width:2.5;marker-end:url(#idxTreeArrow)",[128,136],{"x1":137,"y1":131,"x2":138,"y2":133,"stroke":71,"style":139},"416","520","stroke-width:2;stroke-dasharray:5 4;marker-end:url(#idxTreeArrow)",[141,142,143,150,153,156],"g",{},[49,144],{"x":98,"y":145,"width":146,"height":146,"rx":147,"fill":103,"fillOpacity":148,"stroke":103,"style":149},"192","38","4",0.25,"stroke-width:2",[49,151],{"x":152,"y":145,"width":146,"height":146,"rx":147,"fill":103,"fillOpacity":148,"stroke":103,"style":149},"168",[49,154],{"x":155,"y":145,"width":146,"height":146,"rx":147,"fill":103,"fillOpacity":148,"stroke":103,"style":149},"216",[49,157],{"x":158,"y":145,"width":159,"height":146,"rx":147,"fill":103,"fillOpacity":148,"stroke":103,"style":149},"264","36",[73,161,162],{"x":106,"y":132,"style":113,"fill":114,"textAnchor":79},"4 exact tests",[141,164,165,169,172,175],{},[49,166],{"x":118,"y":145,"width":146,"height":146,"rx":147,"fill":71,"fillOpacity":167,"stroke":71,"style":168},0.12,"stroke-width:1.5;stroke-dasharray:4 3",[49,170],{"x":171,"y":145,"width":146,"height":146,"rx":147,"fill":71,"fillOpacity":167,"stroke":71,"style":168},"508",[49,173],{"x":174,"y":145,"width":146,"height":146,"rx":147,"fill":71,"fillOpacity":167,"stroke":71,"style":168},"556",[49,176],{"x":177,"y":145,"width":159,"height":146,"rx":147,"fill":71,"fillOpacity":167,"stroke":71,"style":168},"604",[73,179,180],{"x":122,"y":132,"style":113,"fill":114,"textAnchor":79},"never examined",[49,182],{"x":152,"y":183,"width":98,"height":184,"rx":147,"fill":185,"stroke":186,"style":187},"176","70","none","#2563eb","stroke-width:2.5;stroke-dasharray:6 4",[73,189,193],{"x":190,"y":191,"style":192,"fill":186,"textAnchor":79},"228","270","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","query rectangle",[195,196,198],"h2",{"id":197},"prerequisites","Prerequisites",[200,201,202,210,217],"ul",{},[203,204,205,209],"li",{},[206,207,208],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer.",[203,211,212,213,216],{},"Two layers ",[206,214,215],{},"in the same CRS"," — the index stores raw coordinates and does no transformation.",[203,218,219],{},"Enough memory for the index. A bare index is small; one storing geometries is roughly the size of the geometry data itself.",[195,221,223],{"id":222},"build-an-index","Build an index",[14,225,226],{},"The constructor takes a feature iterator and consumes it in one pass.",[228,229,234],"pre",{"className":230,"code":231,"language":232,"meta":233,"style":233},"language-python shiki shiki-themes github-dark","from qgis.core import QgsProject, QgsSpatialIndex\n\nzones = QgsProject.instance().mapLayersByName(\"flood_zones\")[0]\n\nindex = QgsSpatialIndex(zones.getFeatures())\nprint(\"indexed\", zones.featureCount(), \"feature(s)\")\n","python","",[18,235,236,254,261,286,291,302],{"__ignoreMap":233},[237,238,240,244,248,251],"span",{"class":128,"line":239},1,[237,241,243],{"class":242},"snl16","from",[237,245,247],{"class":246},"s95oV"," qgis.core ",[237,249,250],{"class":242},"import",[237,252,253],{"class":246}," QgsProject, QgsSpatialIndex\n",[237,255,257],{"class":128,"line":256},2,[237,258,260],{"emptyLinePlaceholder":259},true,"\n",[237,262,264,267,270,273,277,280,283],{"class":128,"line":263},3,[237,265,266],{"class":246},"zones ",[237,268,269],{"class":242},"=",[237,271,272],{"class":246}," QgsProject.instance().mapLayersByName(",[237,274,276],{"class":275},"sU2Wk","\"flood_zones\"",[237,278,279],{"class":246},")[",[237,281,51],{"class":282},"sDLfK",[237,284,285],{"class":246},"]\n",[237,287,289],{"class":128,"line":288},4,[237,290,260],{"emptyLinePlaceholder":259},[237,292,294,297,299],{"class":128,"line":293},5,[237,295,296],{"class":246},"index ",[237,298,269],{"class":242},[237,300,301],{"class":246}," QgsSpatialIndex(zones.getFeatures())\n",[237,303,305,308,311,314,317,320],{"class":128,"line":304},6,[237,306,307],{"class":282},"print",[237,309,310],{"class":246},"(",[237,312,313],{"class":275},"\"indexed\"",[237,315,316],{"class":246},", zones.featureCount(), ",[237,318,319],{"class":275},"\"feature(s)\"",[237,321,322],{"class":246},")\n",[14,324,325,328,329,332,333,336],{},[206,326,327],{},"Breakdown:"," The iterator is consumed completely, so it cannot be reused afterwards — call ",[18,330,331],{},"getFeatures()"," again if you need a second pass. Building the index costs one full read of the layer, which is why it pays off only when you will query it more than a handful of times. For a single lookup, ",[18,334,335],{},"QgsFeatureRequest().setFilterRect()"," is simpler and cheaper.",[14,338,339],{},"By default the index stores only bounding boxes and feature IDs. That keeps it small but means every candidate requires a provider round-trip to fetch its geometry. For a tight loop, storing the geometries inside the index is usually the better trade:",[228,341,343],{"className":230,"code":342,"language":232,"meta":233,"style":233},"index = QgsSpatialIndex(\n    zones.getFeatures(), flags=QgsSpatialIndex.FlagStoreFeatureGeometries\n)\ngeometry = index.geometry(some_feature_id)\n",[18,344,345,354,368,372],{"__ignoreMap":233},[237,346,347,349,351],{"class":128,"line":239},[237,348,296],{"class":246},[237,350,269],{"class":242},[237,352,353],{"class":246}," QgsSpatialIndex(\n",[237,355,356,359,363,365],{"class":128,"line":256},[237,357,358],{"class":246},"    zones.getFeatures(), ",[237,360,362],{"class":361},"s9osk","flags",[237,364,269],{"class":242},[237,366,367],{"class":246},"QgsSpatialIndex.FlagStoreFeatureGeometries\n",[237,369,370],{"class":128,"line":263},[237,371,322],{"class":246},[237,373,374,377,379],{"class":128,"line":288},[237,375,376],{"class":246},"geometry ",[237,378,269],{"class":242},[237,380,381],{"class":246}," index.geometry(some_feature_id)\n",[14,383,384,386,387,390,391,394,395,398],{},[206,385,327],{}," ",[18,388,389],{},"FlagStoreFeatureGeometries"," keeps a copy of every geometry, so ",[18,392,393],{},"index.geometry(id)"," returns it with no provider access at all. The memory cost is proportional to the geometry data — trivial for points, significant for a national polygon coverage. The alternative, building a ",[18,396,397],{},"{fid: geometry}"," dictionary yourself, uses the same memory with more code, so prefer the flag.",[195,400,402],{"id":401},"query-by-rectangle","Query by rectangle",[14,404,405,408,409,412,413,417],{},[18,406,407],{},"intersects()"," on the index takes a ",[18,410,411],{},"QgsRectangle"," and returns candidate feature IDs whose ",[414,415,416],"em",{},"boxes"," overlap it.",[228,419,421],{"className":230,"code":420,"language":232,"meta":233,"style":233},"from qgis.core import QgsProject, QgsSpatialIndex\n\nparcels = QgsProject.instance().mapLayersByName(\"parcels\")[0]\nzones = QgsProject.instance().mapLayersByName(\"flood_zones\")[0]\n\nindex = QgsSpatialIndex(\n    zones.getFeatures(), flags=QgsSpatialIndex.FlagStoreFeatureGeometries\n)\n\nmatches = {}\nfor parcel in parcels.getFeatures():\n    geometry = parcel.geometry()\n    hits = [\n        zone_id\n        for zone_id in index.intersects(geometry.boundingBox())\n        if geometry.intersects(index.geometry(zone_id))\n    ]\n    if hits:\n        matches[parcel[\"parcel_id\"]] = hits\n\nprint(len(matches), \"parcel(s) matched at least one zone\")\n",[18,422,423,433,437,455,471,475,483,494,499,504,515,530,541,552,558,572,581,587,596,613,618],{"__ignoreMap":233},[237,424,425,427,429,431],{"class":128,"line":239},[237,426,243],{"class":242},[237,428,247],{"class":246},[237,430,250],{"class":242},[237,432,253],{"class":246},[237,434,435],{"class":128,"line":256},[237,436,260],{"emptyLinePlaceholder":259},[237,438,439,442,444,446,449,451,453],{"class":128,"line":263},[237,440,441],{"class":246},"parcels ",[237,443,269],{"class":242},[237,445,272],{"class":246},[237,447,448],{"class":275},"\"parcels\"",[237,450,279],{"class":246},[237,452,51],{"class":282},[237,454,285],{"class":246},[237,456,457,459,461,463,465,467,469],{"class":128,"line":288},[237,458,266],{"class":246},[237,460,269],{"class":242},[237,462,272],{"class":246},[237,464,276],{"class":275},[237,466,279],{"class":246},[237,468,51],{"class":282},[237,470,285],{"class":246},[237,472,473],{"class":128,"line":293},[237,474,260],{"emptyLinePlaceholder":259},[237,476,477,479,481],{"class":128,"line":304},[237,478,296],{"class":246},[237,480,269],{"class":242},[237,482,353],{"class":246},[237,484,486,488,490,492],{"class":128,"line":485},7,[237,487,358],{"class":246},[237,489,362],{"class":361},[237,491,269],{"class":242},[237,493,367],{"class":246},[237,495,497],{"class":128,"line":496},8,[237,498,322],{"class":246},[237,500,502],{"class":128,"line":501},9,[237,503,260],{"emptyLinePlaceholder":259},[237,505,507,510,512],{"class":128,"line":506},10,[237,508,509],{"class":246},"matches ",[237,511,269],{"class":242},[237,513,514],{"class":246}," {}\n",[237,516,518,521,524,527],{"class":128,"line":517},11,[237,519,520],{"class":242},"for",[237,522,523],{"class":246}," parcel ",[237,525,526],{"class":242},"in",[237,528,529],{"class":246}," parcels.getFeatures():\n",[237,531,533,536,538],{"class":128,"line":532},12,[237,534,535],{"class":246},"    geometry ",[237,537,269],{"class":242},[237,539,540],{"class":246}," parcel.geometry()\n",[237,542,544,547,549],{"class":128,"line":543},13,[237,545,546],{"class":246},"    hits ",[237,548,269],{"class":242},[237,550,551],{"class":246}," [\n",[237,553,555],{"class":128,"line":554},14,[237,556,557],{"class":246},"        zone_id\n",[237,559,561,564,567,569],{"class":128,"line":560},15,[237,562,563],{"class":242},"        for",[237,565,566],{"class":246}," zone_id ",[237,568,526],{"class":242},[237,570,571],{"class":246}," index.intersects(geometry.boundingBox())\n",[237,573,575,578],{"class":128,"line":574},16,[237,576,577],{"class":242},"        if",[237,579,580],{"class":246}," geometry.intersects(index.geometry(zone_id))\n",[237,582,584],{"class":128,"line":583},17,[237,585,586],{"class":246},"    ]\n",[237,588,590,593],{"class":128,"line":589},18,[237,591,592],{"class":242},"    if",[237,594,595],{"class":246}," hits:\n",[237,597,599,602,605,608,610],{"class":128,"line":598},19,[237,600,601],{"class":246},"        matches[parcel[",[237,603,604],{"class":275},"\"parcel_id\"",[237,606,607],{"class":246},"]] ",[237,609,269],{"class":242},[237,611,612],{"class":246}," hits\n",[237,614,616],{"class":128,"line":615},20,[237,617,260],{"emptyLinePlaceholder":259},[237,619,621,623,625,628,631,634],{"class":128,"line":620},21,[237,622,307],{"class":282},[237,624,310],{"class":246},[237,626,627],{"class":282},"len",[237,629,630],{"class":246},"(matches), ",[237,632,633],{"class":275},"\"parcel(s) matched at least one zone\"",[237,635,322],{"class":246},[14,637,638,640,641,644,645,648,649,652],{},[206,639,327],{}," The two-stage structure is the whole point and must not be collapsed. ",[18,642,643],{},"index.intersects()"," is an approximation — it returns everything whose box overlaps, including features the exact geometry misses entirely — so the inner ",[18,646,647],{},"geometry.intersects()"," is what produces the correct answer. Skipping it gives wrong results that look plausible, which is the worst kind. Note the index is built on the ",[414,650,651],{},"smaller"," layer where possible: the layer you index is the one you look things up in, and the layer you iterate is the one you scan.",[195,654,656],{"id":655},"query-by-proximity","Query by proximity",[14,658,659,662],{},[18,660,661],{},"nearestNeighbor()"," answers \"what is closest to this\" without any distance computation on your side.",[228,664,666],{"className":230,"code":665,"language":232,"meta":233,"style":233},"schools = QgsProject.instance().mapLayersByName(\"schools\")[0]\n\nfor school in schools.getFeatures():\n    geometry = school.geometry()\n    candidates = index.nearestNeighbor(geometry, 5)\n    nearest = min(\n        candidates, key=lambda fid: geometry.distance(index.geometry(fid)), default=None\n    )\n    if nearest is not None:\n        print(school[\"name\"], \"->\", nearest)\n",[18,667,668,686,690,702,711,725,738,760,765,784],{"__ignoreMap":233},[237,669,670,673,675,677,680,682,684],{"class":128,"line":239},[237,671,672],{"class":246},"schools ",[237,674,269],{"class":242},[237,676,272],{"class":246},[237,678,679],{"class":275},"\"schools\"",[237,681,279],{"class":246},[237,683,51],{"class":282},[237,685,285],{"class":246},[237,687,688],{"class":128,"line":256},[237,689,260],{"emptyLinePlaceholder":259},[237,691,692,694,697,699],{"class":128,"line":263},[237,693,520],{"class":242},[237,695,696],{"class":246}," school ",[237,698,526],{"class":242},[237,700,701],{"class":246}," schools.getFeatures():\n",[237,703,704,706,708],{"class":128,"line":288},[237,705,535],{"class":246},[237,707,269],{"class":242},[237,709,710],{"class":246}," school.geometry()\n",[237,712,713,716,718,721,723],{"class":128,"line":293},[237,714,715],{"class":246},"    candidates ",[237,717,269],{"class":242},[237,719,720],{"class":246}," index.nearestNeighbor(geometry, ",[237,722,64],{"class":282},[237,724,322],{"class":246},[237,726,727,730,732,735],{"class":128,"line":304},[237,728,729],{"class":246},"    nearest ",[237,731,269],{"class":242},[237,733,734],{"class":282}," min",[237,736,737],{"class":246},"(\n",[237,739,740,743,746,749,752,755,757],{"class":128,"line":485},[237,741,742],{"class":246},"        candidates, ",[237,744,745],{"class":361},"key",[237,747,748],{"class":242},"=lambda",[237,750,751],{"class":246}," fid: geometry.distance(index.geometry(fid)), ",[237,753,754],{"class":361},"default",[237,756,269],{"class":242},[237,758,759],{"class":282},"None\n",[237,761,762],{"class":128,"line":496},[237,763,764],{"class":246},"    )\n",[237,766,767,769,772,775,778,781],{"class":128,"line":501},[237,768,592],{"class":242},[237,770,771],{"class":246}," nearest ",[237,773,774],{"class":242},"is",[237,776,777],{"class":242}," not",[237,779,780],{"class":282}," None",[237,782,783],{"class":246},":\n",[237,785,786,789,792,795,798,801],{"class":128,"line":506},[237,787,788],{"class":282},"        print",[237,790,791],{"class":246},"(school[",[237,793,794],{"class":275},"\"name\"",[237,796,797],{"class":246},"], ",[237,799,800],{"class":275},"\"->\"",[237,802,803],{"class":246},", nearest)\n",[14,805,806,386,808,811,812,815,816,819,820,823,824,828],{},[206,807,327],{},[18,809,810],{},"nearestNeighbor(geometry, 5)"," ranks by bounding-box proximity, which can mis-order long or irregular shapes — a long diagonal river has a huge box whose corner may be nearer than a compact feature that is genuinely closer. Requesting several candidates and picking the true minimum with ",[18,813,814],{},"distance()"," corrects that. ",[18,817,818],{},"min(..., default=None)"," handles the empty-index case without an extra branch. For a whole-layer result, ",[18,821,822],{},"native:joinbynearest"," does the same thing and writes an output layer — see ",[26,825,827],{"href":826},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fcalculate-distance-between-features-pyqgis\u002F","Calculate the Distance Between Features",".",[195,830,832],{"id":831},"keep-the-index-in-step-with-edits","Keep the index in step with edits",[14,834,835],{},"An index is a snapshot. Add, move or delete a feature in the layer and the index knows nothing about it, so queries silently return stale results.",[14,837,838],{},[34,839,842,845,848,851,858,861,869,874,878,883,887,891,895,898,901,904,908,912,918,922,926,931,935,939],{"viewBox":840,"role":37,"ariaLabel":841,"xmlns":39},"0 0 760 232","An index built from a layer becomes stale after the layer is edited, with three options shown: incremental update, full rebuild, or building the index after all edits are committed",[41,843,844],{},"Three ways to keep an index correct",[45,846,847],{},"A layer feeds an index. When the layer is edited the index becomes stale. Three remedies are shown: calling addFeature and deleteFeature to update it incrementally, rebuilding the whole index from scratch, or deferring index construction until after all edits are committed, which is labelled as the simplest option.",[49,849],{"x":51,"y":51,"width":52,"height":850,"fill":54},"232",[56,852,853],{},[59,854,856],{"id":855,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"idxSyncArrow",[68,857],{"d":70,"fill":114},[73,859,860],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"An index does not watch the layer — you keep them in step",[49,862],{"x":863,"y":864,"width":865,"height":866,"rx":867,"fill":102,"stroke":868,"style":89},"16","82","140","66","10","#b91c1c",[73,870,873],{"x":871,"y":872,"style":93,"fill":868,"textAnchor":79},"86","108","layer edited",[73,875,877],{"x":871,"y":876,"style":113,"fill":114,"textAnchor":79},"130","index now stale",[49,879],{"x":880,"y":101,"width":881,"height":882,"rx":63,"fill":102,"stroke":186,"style":149},"256","248","46",[73,884,886],{"x":75,"y":885,"style":108,"fill":186,"textAnchor":79},"62","index.addFeature \u002F deleteFeature",[73,888,890],{"x":75,"y":889,"style":113,"fill":114,"textAnchor":79},"79","cheap, but every edit must be mirrored",[49,892],{"x":880,"y":893,"width":881,"height":882,"rx":63,"fill":102,"stroke":894,"style":149},"100","#b45309",[73,896,897],{"x":75,"y":98,"style":108,"fill":894,"textAnchor":79},"rebuild from getFeatures()",[73,899,900],{"x":75,"y":107,"style":113,"fill":114,"textAnchor":79},"simple, costs one full read",[49,902],{"x":880,"y":903,"width":881,"height":882,"rx":63,"fill":102,"stroke":103,"style":89},"158",[73,905,907],{"x":75,"y":906,"style":108,"fill":103,"textAnchor":79},"178","build after commitChanges()",[73,909,911],{"x":75,"y":910,"style":113,"fill":114,"textAnchor":79},"195","no synchronisation problem at all",[128,913],{"x1":914,"y1":915,"x2":916,"y2":866,"stroke":114,"style":917},"156","104","252","stroke-width:2;marker-end:url(#idxSyncArrow)",[128,919],{"x1":914,"y1":920,"x2":916,"y2":921,"stroke":114,"style":917},"115","122",[128,923],{"x1":914,"y1":924,"x2":916,"y2":906,"stroke":103,"style":925},"126","stroke-width:2.5;marker-end:url(#idxSyncArrow)",[49,927],{"x":928,"y":865,"width":929,"height":930,"rx":867,"fill":102,"stroke":103,"style":89},"544","200","64",[73,932,934],{"x":933,"y":152,"style":93,"fill":103,"textAnchor":79},"644","prefer this",[73,936,938],{"x":933,"y":937,"style":113,"fill":114,"textAnchor":79},"190","read-only index, no drift",[128,940],{"x1":941,"y1":100,"x2":942,"y2":183,"stroke":103,"style":925},"504","540",[228,944,946],{"className":230,"code":945,"language":232,"meta":233,"style":233},"from qgis.core import QgsSpatialIndex\n\nindex = QgsSpatialIndex(zones.getFeatures())\n\nnew_feature = make_zone()                     # your own construction\nzones.dataProvider().addFeatures([new_feature])\nindex.addFeature(new_feature)                 # mirror the change\n\nzones.dataProvider().deleteFeatures([old_id])\nindex.deleteFeature(old_zone_feature)         # takes the feature, not the id\n",[18,947,948,959,963,971,975,989,994,1002,1006,1011],{"__ignoreMap":233},[237,949,950,952,954,956],{"class":128,"line":239},[237,951,243],{"class":242},[237,953,247],{"class":246},[237,955,250],{"class":242},[237,957,958],{"class":246}," QgsSpatialIndex\n",[237,960,961],{"class":128,"line":256},[237,962,260],{"emptyLinePlaceholder":259},[237,964,965,967,969],{"class":128,"line":263},[237,966,296],{"class":246},[237,968,269],{"class":242},[237,970,301],{"class":246},[237,972,973],{"class":128,"line":288},[237,974,260],{"emptyLinePlaceholder":259},[237,976,977,980,982,985],{"class":128,"line":293},[237,978,979],{"class":246},"new_feature ",[237,981,269],{"class":242},[237,983,984],{"class":246}," make_zone()                     ",[237,986,988],{"class":987},"sjoCn","# your own construction\n",[237,990,991],{"class":128,"line":304},[237,992,993],{"class":246},"zones.dataProvider().addFeatures([new_feature])\n",[237,995,996,999],{"class":128,"line":485},[237,997,998],{"class":246},"index.addFeature(new_feature)                 ",[237,1000,1001],{"class":987},"# mirror the change\n",[237,1003,1004],{"class":128,"line":496},[237,1005,260],{"emptyLinePlaceholder":259},[237,1007,1008],{"class":128,"line":501},[237,1009,1010],{"class":246},"zones.dataProvider().deleteFeatures([old_id])\n",[237,1012,1013,1016],{"class":128,"line":506},[237,1014,1015],{"class":246},"index.deleteFeature(old_zone_feature)         ",[237,1017,1018],{"class":987},"# takes the feature, not the id\n",[14,1020,1021,386,1023,1026,1027,1030,1031,1033],{},[206,1022,327],{},[18,1024,1025],{},"addFeature()"," and ",[18,1028,1029],{},"deleteFeature()"," update the tree incrementally, which is far cheaper than rebuilding. The asymmetry catches people out: ",[18,1032,1029],{}," needs the whole feature — including its geometry, so the tree can find the right node — not just the ID, which means you have to hold on to it before deleting from the provider. In practice the simplest correct approach is to finish all edits, commit, and only then build the index; a read-only index cannot drift.",[195,1035,1037],{"id":1036},"when-an-index-is-not-the-answer","When an index is not the answer",[14,1039,1040],{},"Building an index costs a full read of the layer. That is only worth it when you will query it many times, and there are two situations where something else is simply better.",[14,1042,1043],{},[34,1044,1047,1050,1053,1056,1063,1066,1069,1073,1077,1082,1086,1089,1092,1094,1097,1101,1104,1109,1111,1115,1118,1122,1124,1127,1129],{"viewBox":1045,"role":37,"ariaLabel":1046,"xmlns":39},"0 0 760 258","A decision tree choosing between a provider-side filter, a single feature request, a spatial index and a database spatial join, based on the number of queries and where the data lives",[41,1048,1049],{},"Choosing the right tool for a spatial lookup",[45,1051,1052],{},"Starting from a spatial lookup, three branches. If only a handful of queries are needed, a single feature request with a filter rectangle wins. If many queries are needed against a file-based layer, a spatial index wins. If both layers live in the same PostGIS database, a SQL spatial join beats anything done in Python.",[49,1054],{"x":51,"y":51,"width":52,"height":1055,"fill":54},"258",[56,1057,1058],{},[59,1059,1061],{"id":1060,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"idxPickArrow",[68,1062],{"d":70,"fill":114},[73,1064,1065],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"How many lookups, and where does the data live?",[49,1067],{"x":1068,"y":882,"width":100,"height":882,"rx":63,"fill":87,"stroke":88,"style":89},"290",[73,1070,1072],{"x":75,"y":1071,"style":93,"fill":94,"textAnchor":79},"74","a spatial lookup",[49,1074],{"x":863,"y":1075,"width":155,"height":1076,"rx":63,"fill":102,"stroke":186,"style":89},"128","56",[73,1078,1081],{"x":1079,"y":1080,"style":108,"fill":186,"textAnchor":79},"124","150","fewer than ~100 queries",[73,1083,1085],{"x":1079,"y":1084,"style":113,"fill":114,"textAnchor":79},"170","setFilterRect() on a request",[49,1087],{"x":1088,"y":1075,"width":155,"height":1076,"rx":63,"fill":102,"stroke":103,"style":89},"272",[73,1090,1091],{"x":75,"y":1080,"style":108,"fill":103,"textAnchor":79},"many queries, file layer",[73,1093,20],{"x":75,"y":1084,"style":113,"fill":114,"textAnchor":79},[49,1095],{"x":1096,"y":1075,"width":155,"height":1076,"rx":63,"fill":102,"stroke":894,"style":89},"528",[73,1098,1100],{"x":1099,"y":1080,"style":108,"fill":894,"textAnchor":79},"636","both layers in PostGIS",[73,1102,1103],{"x":1099,"y":1084,"style":113,"fill":114,"textAnchor":79},"a SQL spatial join",[128,1105],{"x1":1106,"y1":1107,"x2":1080,"y2":1079,"stroke":114,"style":1108},"330","92","stroke-width:2;marker-end:url(#idxPickArrow)",[128,1110],{"x1":75,"y1":1107,"x2":75,"y2":1079,"stroke":114,"style":1108},[128,1112],{"x1":1113,"y1":1107,"x2":1114,"y2":1079,"stroke":114,"style":1108},"430","610",[49,1116],{"x":863,"y":929,"width":155,"height":101,"rx":63,"fill":102,"stroke":71,"style":1117},"stroke-width:1.5",[73,1119,1121],{"x":1079,"y":1120,"style":113,"fill":114,"textAnchor":79},"226","no build cost to amortise",[49,1123],{"x":1088,"y":929,"width":155,"height":101,"rx":63,"fill":102,"stroke":71,"style":1117},[73,1125,1126],{"x":75,"y":1120,"style":113,"fill":114,"textAnchor":79},"one read, then thousands of lookups",[49,1128],{"x":1096,"y":929,"width":155,"height":101,"rx":63,"fill":102,"stroke":71,"style":1117},[73,1130,1131],{"x":1099,"y":1120,"style":113,"fill":114,"textAnchor":79},"the database already has an index",[14,1133,1134],{},"For a handful of lookups, skip the index entirely:",[228,1136,1138],{"className":230,"code":1137,"language":232,"meta":233,"style":233},"from qgis.core import QgsFeatureRequest\n\nrequest = QgsFeatureRequest().setFilterRect(query_geometry.boundingBox())\ncandidates = [\n    f for f in zones.getFeatures(request)\n    if query_geometry.intersects(f.geometry())\n]\n",[18,1139,1140,1151,1155,1165,1174,1189,1196],{"__ignoreMap":233},[237,1141,1142,1144,1146,1148],{"class":128,"line":239},[237,1143,243],{"class":242},[237,1145,247],{"class":246},[237,1147,250],{"class":242},[237,1149,1150],{"class":246}," QgsFeatureRequest\n",[237,1152,1153],{"class":128,"line":256},[237,1154,260],{"emptyLinePlaceholder":259},[237,1156,1157,1160,1162],{"class":128,"line":263},[237,1158,1159],{"class":246},"request ",[237,1161,269],{"class":242},[237,1163,1164],{"class":246}," QgsFeatureRequest().setFilterRect(query_geometry.boundingBox())\n",[237,1166,1167,1170,1172],{"class":128,"line":288},[237,1168,1169],{"class":246},"candidates ",[237,1171,269],{"class":242},[237,1173,551],{"class":246},[237,1175,1176,1179,1181,1184,1186],{"class":128,"line":293},[237,1177,1178],{"class":246},"    f ",[237,1180,520],{"class":242},[237,1182,1183],{"class":246}," f ",[237,1185,526],{"class":242},[237,1187,1188],{"class":246}," zones.getFeatures(request)\n",[237,1190,1191,1193],{"class":128,"line":304},[237,1192,592],{"class":242},[237,1194,1195],{"class":246}," query_geometry.intersects(f.geometry())\n",[237,1197,1198],{"class":128,"line":485},[237,1199,285],{"class":246},[14,1201,1202,386,1204,1207,1208,1211,1212,1214],{},[206,1203,327],{},[18,1205,1206],{},"setFilterRect()"," pushes the box test to the provider, which for a GeoPackage or PostGIS layer uses the ",[414,1209,1210],{},"file or database's own"," spatial index — one that already exists and cost you nothing to build. For a small number of queries this beats building a ",[18,1213,20],{}," outright, and the two-stage exact test is identical.",[14,1216,1217,1218,828],{},"When both layers live in the same PostGIS database, neither approach is right: the join belongs in SQL, where the planner can use both tables' indexes and never transfer a row into Python. Load the result as a layer with a query-backed URI rather than iterating in PyQGIS at all — the same principle as pushing filters down described in ",[26,1219,1221],{"href":1220},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-expressions\u002F","Working with QGIS Expressions",[195,1223,1225],{"id":1224},"qgis-version-compatibility","QGIS version compatibility",[14,1227,1228,1229,1231],{},"The examples target ",[206,1230,208],{}," (Python 3.12).",[1233,1234,1235,1251],"table",{},[1236,1237,1238],"thead",{},[1239,1240,1241,1245,1248],"tr",{},[1242,1243,1244],"th",{},"QGIS version",[1242,1246,1247],{},"Python",[1242,1249,1250],{},"Notes",[1252,1253,1254,1269,1280],"tbody",{},[1239,1255,1256,1260,1263],{},[1257,1258,1259],"td",{},"3.28 LTR",[1257,1261,1262],{},"3.9",[1257,1264,1265,1266,1268],{},"Identical API. ",[18,1267,389],{}," available since 3.4.",[1239,1270,1271,1274,1277],{},[1257,1272,1273],{},"3.34 LTR",[1257,1275,1276],{},"3.12",[1257,1278,1279],{},"Baseline for this page.",[1239,1281,1282,1285,1287],{},[1257,1283,1284],{},"3.40 \u002F 3.44",[1257,1286,1276],{},[1257,1288,1289,1292],{},[18,1290,1291],{},"QgsSpatialIndexKDBush"," added for point-only layers — faster and smaller where it applies.",[14,1294,1295,1297,1298,1026,1300,1302,1303,1305],{},[18,1296,20],{},", ",[18,1299,407],{},[18,1301,661],{}," are unchanged across 3.x. On a points-only layer in 3.40 or newer, ",[18,1304,1291],{}," is worth benchmarking.",[195,1307,1309],{"id":1308},"troubleshooting","Troubleshooting",[200,1311,1312,1321,1327,1336,1350,1359],{},[203,1313,1314,1317,1318,1320],{},[206,1315,1316],{},"Results include features that clearly do not match."," The exact predicate step was skipped. ",[18,1319,643],{}," returns box candidates, not answers.",[203,1322,1323,1326],{},[206,1324,1325],{},"Results are missing features that should match."," The two layers are in different CRSs, or the index was built before the features were added.",[203,1328,1329,1332,1333,1335],{},[206,1330,1331],{},"The index appears empty."," The feature iterator was already consumed. ",[18,1334,331],{}," returns a single-use iterator — call it fresh for the constructor.",[203,1337,1338,1343,1344,1346,1347,1349],{},[206,1339,1340,1342],{},[18,1341,393],{}," raises or returns nothing."," The index was built without ",[18,1345,389],{},". Either add the flag or keep your own ",[18,1348,397],{}," dictionary.",[203,1351,1352,1355,1356,1358],{},[206,1353,1354],{},"Building the index takes longer than the query saved."," You are querying too few times. Below roughly a hundred lookups, a plain ",[18,1357,1206],{}," request is faster overall.",[203,1360,1361,1364,1365,828],{},[206,1362,1363],{},"Nearest neighbour picks something visibly further away."," Only one candidate was requested. Ask for five and confirm with an exact ",[18,1366,814],{},[195,1368,1370],{"id":1369},"conclusion","Conclusion",[14,1372,1373,1374,1376],{},"A spatial index converts a quadratic scan into a tree descent plus a few exact tests. Build it on the layer you look things up in, add ",[18,1375,389],{}," when you will fetch geometries in a loop, always follow a box query with the exact predicate, and build the index after edits are committed rather than trying to keep a live one in step.",[195,1378,1380],{"id":1379},"frequently-asked-questions","Frequently Asked Questions",[14,1382,1383,1386,1387,1297,1389,1392,1393,1395],{},[206,1384,1385],{},"Does the index give me the exact answer?","\nNo. It returns candidates whose bounding boxes match, which is an approximation containing false positives. Always follow it with the exact predicate — ",[18,1388,407],{},[18,1390,1391],{},"contains()"," or ",[18,1394,814],{}," — on each candidate.",[14,1397,1398,1401,1402,1404],{},[206,1399,1400],{},"Should I store geometries in the index?","\nYes, if you will fetch candidate geometries inside a loop. ",[18,1403,389],{}," avoids a provider round-trip per candidate at the cost of memory proportional to the geometry data.",[14,1406,1407,1410],{},[206,1408,1409],{},"Which layer should I index?","\nThe one you look things up in, not the one you iterate. If you are asking \"which zone does each parcel fall in\", index the zones and iterate the parcels.",[14,1412,1413,1416,1417,1026,1419,1421],{},[206,1414,1415],{},"What happens to the index when I edit the layer?","\nNothing — it silently goes stale. Mirror each change with ",[18,1418,1025],{},[18,1420,1029],{},", or simply build the index after committing your edits so it never needs synchronising.",[14,1423,1424,1427],{},[206,1425,1426],{},"Does the index work across coordinate reference systems?","\nNo. It stores raw coordinates, so a query geometry in a different CRS matches nothing. Reproject one side before building or querying the index.",[195,1429,1431],{"id":1430},"related","Related",[200,1433,1434,1439,1445,1450],{},[203,1435,1436,1438],{},[26,1437,29],{"href":28}," — the guide this recipe belongs to",[203,1440,1441],{},[26,1442,1444],{"href":1443},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fcheck-geometry-intersects-pyqgis\u002F","Test Whether Two Geometries Intersect in PyQGIS",[203,1446,1447],{},[26,1448,1449],{"href":826},"Calculate the Distance Between Features in PyQGIS",[203,1451,1452],{},[26,1453,1455],{"href":1454},"\u002Fspatial-data-processing-automation\u002Fbatch-processing-with-pyqgis\u002F","Batch Processing with PyQGIS",[1457,1458,1459],"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 .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}",{"title":233,"searchDepth":256,"depth":256,"links":1461},[1462,1463,1464,1465,1466,1467,1468,1469,1470,1471,1472],{"id":197,"depth":256,"text":198},{"id":222,"depth":256,"text":223},{"id":401,"depth":256,"text":402},{"id":655,"depth":256,"text":656},{"id":831,"depth":256,"text":832},{"id":1036,"depth":256,"text":1037},{"id":1224,"depth":256,"text":1225},{"id":1308,"depth":256,"text":1309},{"id":1369,"depth":256,"text":1370},{"id":1379,"depth":256,"text":1380},{"id":1430,"depth":256,"text":1431},"Build a QgsSpatialIndex in PyQGIS to speed up spatial joins and nearest-neighbour queries, choose whether to store geometries, and keep the index in step with edits.","md",{"slug":1476,"type":1477,"breadcrumb":1478,"datePublished":1479,"dateModified":1479},"build-spatial-index-pyqgis","article","Spatial Index","2026-08-01","\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuild-spatial-index-pyqgis",{"title":5,"description":1473},"spatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuild-spatial-index-pyqgis\u002Findex","s11a6TMJbW8mAAfZb0kxMaL3v79UtTbAAW6GmgPLlgY",1785585990477]