[{"data":1,"prerenderedAt":1506},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002Fspatial-join-points-in-polygons-pyqgis":3},{"id":4,"title":5,"body":6,"description":1495,"extension":1496,"meta":1497,"navigation":206,"path":1502,"seo":1503,"stem":1504,"__hash__":1505},"docs\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002Fspatial-join-points-in-polygons-pyqgis\u002Findex.md","Spatial Join Points to Polygons in PyQGIS",{"type":7,"value":8,"toc":1481},"minimark",[9,13,17,26,152,157,174,178,350,379,383,386,497,513,517,520,623,659,765,769,772,944,966,970,973,995,1011,1015,1018,1137,1153,1156,1243,1247,1253,1312,1316,1377,1381,1398,1402,1408,1420,1430,1436,1442,1446,1477],[10,11,5],"h1",{"id":12},"spatial-join-points-to-polygons-in-pyqgis",[14,15,16],"p",{},"\"Which ward is each incident in?\" is a spatial join, and it is probably the most-used spatial operation there is. The algorithm is straightforward; the decisions around it are not. A point on a boundary can match two polygons. A point in a gap matches none. And the difference between joining attributes onto points and summarising points onto polygons is the difference between two completely different output layers — both of which people describe as \"a spatial join\".",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002F","Vector Data Manipulation in PyQGIS",". It covers the join algorithm and its predicates, the two join types, summarising the reverse direction, and what to do about unmatched and doubly-matched points.",[14,27,28],{},[29,30,35,39,43,50,59,66,70,74,80,84,89,95,98,102,105,108,113,118,125,132,135,139,143,147],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 276","img","Points inside three polygons, showing one point on a shared boundary matching two polygons and one point outside all polygons matching none","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"The two cases that need a decision",[40,41,42],"desc",{},"Points are scattered over three adjacent polygons. Most fall clearly inside one polygon and take its attributes. One point sits exactly on a shared boundary and satisfies the intersects predicate for both neighbours. One point lies outside every polygon and matches nothing, so the join type decides whether it survives.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","276","#f6f3ea",[51,52,58],"text",{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","Most points are easy — plan for the two that are not",[60,61],"path",{"d":62,"fill":63,"fillOpacity":64,"stroke":63,"style":65},"M60 60 L260 60 L260 200 L60 200 Z","#2563eb",0.16,"stroke-width:2",[60,67],{"d":68,"fill":69,"fillOpacity":64,"stroke":69,"style":65},"M260 60 L440 60 L440 200 L260 200 Z","#0f766e",[60,71],{"d":72,"fill":73,"fillOpacity":64,"stroke":73,"style":65},"M60 200 L440 200 L440 240 L60 240 Z","#b45309",[51,75,79],{"x":76,"y":77,"style":78,"fill":63,"textAnchor":57},"160","88","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","W1",[51,81,83],{"x":82,"y":77,"style":78,"fill":69,"textAnchor":57},"350","W2",[51,85,88],{"x":86,"y":87,"style":78,"fill":73,"textAnchor":57},"250","226","W3",[90,91],"circle",{"cx":92,"cy":92,"r":93,"fill":94},"120","5","#15803d",[90,96],{"cx":97,"cy":76,"r":93,"fill":94},"190",[90,99],{"cx":100,"cy":101,"r":93,"fill":94},"330","130",[90,103],{"cx":53,"cy":104,"r":93,"fill":94},"170",[90,106],{"cx":76,"cy":107,"r":93,"fill":94},"220",[90,109],{"cx":110,"cy":111,"r":112,"fill":73},"260","140","6",[90,114],{"cx":115,"cy":116,"r":112,"fill":117},"530","110","#b91c1c",[44,119],{"x":120,"y":121,"width":122,"height":123,"rx":112,"fill":124,"stroke":73,"style":65},"470","150","274","52","#fdf2e2",[51,126,131],{"x":127,"y":128,"style":129,"fill":130,"textAnchor":57},"607","172","text-anchor:middle;font-size:11px;font-family:sans-serif","#2f3b35","on the boundary — matches W1 and W2",[51,133,134],{"x":127,"y":97,"style":129,"fill":73,"textAnchor":57},"two output rows unless you take the first",[44,136],{"x":120,"y":137,"width":122,"height":123,"rx":112,"fill":138,"stroke":117,"style":65},"60","#fffdf7",[51,140,142],{"x":127,"y":141,"style":129,"fill":130,"textAnchor":57},"82","outside every polygon — no match",[51,144,146],{"x":127,"y":145,"style":129,"fill":117,"textAnchor":57},"100","dropped, or kept with null attributes",[51,148,151],{"x":53,"y":149,"style":129,"fill":150,"textAnchor":57},"264","#59645f","Count the input and output features — if they differ, one of these two cases is why",[153,154,156],"h2",{"id":155},"prerequisites","Prerequisites",[158,159,160,168,171],"ul",{},[161,162,163,167],"li",{},[164,165,166],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer.",[161,169,170],{},"A point layer and a polygon layer. They may be in different CRSs — the algorithm reprojects — but matching them beforehand avoids surprises.",[161,172,173],{},"Valid polygon geometry; an invalid polygon can fail a predicate that visually should succeed.",[153,175,177],{"id":176},"join-polygon-attributes-onto-points","Join polygon attributes onto points",[179,180,185],"pre",{"className":181,"code":182,"language":183,"meta":184,"style":184},"language-python shiki shiki-themes github-dark","import processing\n\nprocessing.run(\"native:joinattributesbylocation\", {\n    \"INPUT\": \"\u002Fdata\u002Fincidents.gpkg|layername=incidents\",\n    \"JOIN\": \"\u002Fdata\u002Fwards.gpkg|layername=wards\",\n    \"PREDICATE\": [0],                     # intersects\n    \"JOIN_FIELDS\": [\"ward_code\", \"ward_name\"],\n    \"METHOD\": 1,                          # first matching feature only\n    \"DISCARD_NONMATCHING\": False,\n    \"PREFIX\": \"\",\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fincidents_ward.gpkg\",\n})\n","python","",[186,187,188,201,208,221,236,249,268,288,305,318,331,344],"code",{"__ignoreMap":184},[189,190,193,197],"span",{"class":191,"line":192},"line",1,[189,194,196],{"class":195},"snl16","import",[189,198,200],{"class":199},"s95oV"," processing\n",[189,202,204],{"class":191,"line":203},2,[189,205,207],{"emptyLinePlaceholder":206},true,"\n",[189,209,211,214,218],{"class":191,"line":210},3,[189,212,213],{"class":199},"processing.run(",[189,215,217],{"class":216},"sU2Wk","\"native:joinattributesbylocation\"",[189,219,220],{"class":199},", {\n",[189,222,224,227,230,233],{"class":191,"line":223},4,[189,225,226],{"class":216},"    \"INPUT\"",[189,228,229],{"class":199},": ",[189,231,232],{"class":216},"\"\u002Fdata\u002Fincidents.gpkg|layername=incidents\"",[189,234,235],{"class":199},",\n",[189,237,239,242,244,247],{"class":191,"line":238},5,[189,240,241],{"class":216},"    \"JOIN\"",[189,243,229],{"class":199},[189,245,246],{"class":216},"\"\u002Fdata\u002Fwards.gpkg|layername=wards\"",[189,248,235],{"class":199},[189,250,252,255,258,261,264],{"class":191,"line":251},6,[189,253,254],{"class":216},"    \"PREDICATE\"",[189,256,257],{"class":199},": [",[189,259,46],{"class":260},"sDLfK",[189,262,263],{"class":199},"],                     ",[189,265,267],{"class":266},"sjoCn","# intersects\n",[189,269,271,274,276,279,282,285],{"class":191,"line":270},7,[189,272,273],{"class":216},"    \"JOIN_FIELDS\"",[189,275,257],{"class":199},[189,277,278],{"class":216},"\"ward_code\"",[189,280,281],{"class":199},", ",[189,283,284],{"class":216},"\"ward_name\"",[189,286,287],{"class":199},"],\n",[189,289,291,294,296,299,302],{"class":191,"line":290},8,[189,292,293],{"class":216},"    \"METHOD\"",[189,295,229],{"class":199},[189,297,298],{"class":260},"1",[189,300,301],{"class":199},",                          ",[189,303,304],{"class":266},"# first matching feature only\n",[189,306,308,311,313,316],{"class":191,"line":307},9,[189,309,310],{"class":216},"    \"DISCARD_NONMATCHING\"",[189,312,229],{"class":199},[189,314,315],{"class":260},"False",[189,317,235],{"class":199},[189,319,321,324,326,329],{"class":191,"line":320},10,[189,322,323],{"class":216},"    \"PREFIX\"",[189,325,229],{"class":199},[189,327,328],{"class":216},"\"\"",[189,330,235],{"class":199},[189,332,334,337,339,342],{"class":191,"line":333},11,[189,335,336],{"class":216},"    \"OUTPUT\"",[189,338,229],{"class":199},[189,340,341],{"class":216},"\"\u002Fdata\u002Foutput\u002Fincidents_ward.gpkg\"",[189,343,235],{"class":199},[189,345,347],{"class":191,"line":346},12,[189,348,349],{"class":199},"})\n",[14,351,352,355,356,359,360,363,364,366,367,370,371,374,375,378],{},[164,353,354],{},"Breakdown:"," ",[186,357,358],{},"INPUT"," is the layer that keeps its geometry and gains columns — points, here. ",[186,361,362],{},"PREDICATE"," is a list of integer codes; ",[186,365,46],{}," is intersects, and for point-in-polygon work it is the correct and most permissive choice. ",[186,368,369],{},"JOIN_FIELDS"," limits the columns copied across; leaving it empty copies everything, which is how a point layer ends up with forty inherited attributes nobody wanted. ",[186,372,373],{},"METHOD: 1"," takes only the first matching polygon, which is what makes a boundary point produce one row instead of two. ",[186,376,377],{},"DISCARD_NONMATCHING: False"," keeps unmatched points with null values — usually right, because a vanished point is much harder to notice than a null.",[153,380,382],{"id":381},"choose-the-predicate-deliberately","Choose the predicate deliberately",[14,384,385],{},"The predicate codes are positional and easy to get wrong:",[387,388,389,405],"table",{},[390,391,392],"thead",{},[393,394,395,399,402],"tr",{},[396,397,398],"th",{},"Code",[396,400,401],{},"Predicate",[396,403,404],{},"Point-in-polygon behaviour",[406,407,408,419,429,444,455,466,476,486],"tbody",{},[393,409,410,413,416],{},[411,412,46],"td",{},[411,414,415],{},"intersects",[411,417,418],{},"Matches inside and exactly on the boundary. The usual choice.",[393,420,421,423,426],{},[411,422,298],{},[411,424,425],{},"contains",[411,427,428],{},"Join feature contains the input; equivalent for points strictly inside.",[393,430,431,434,437],{},[411,432,433],{},"2",[411,435,436],{},"disjoint",[411,438,439,440,443],{},"Matches points ",[164,441,442],{},"outside"," — useful for finding the strays.",[393,445,446,449,452],{},[411,447,448],{},"3",[411,450,451],{},"equals",[411,453,454],{},"Only identical geometry; not useful for point-in-polygon.",[393,456,457,460,463],{},[411,458,459],{},"4",[411,461,462],{},"touches",[411,464,465],{},"Boundary only — matches a point exactly on the edge and nothing inside.",[393,467,468,470,473],{},[411,469,93],{},[411,471,472],{},"overlaps",[411,474,475],{},"Requires partial overlap of same-dimension geometry; never matches a point in a polygon.",[393,477,478,480,483],{},[411,479,112],{},[411,481,482],{},"within",[411,484,485],{},"Input is within the join feature — the strict \"inside\" test.",[393,487,488,491,494],{},[411,489,490],{},"7",[411,492,493],{},"crosses",[411,495,496],{},"Requires the geometries to cross; not applicable to points in polygons.",[14,498,499,355,501,503,504,506,507,509,510,512],{},[164,500,354],{},[186,502,415],{}," and ",[186,505,482],{}," differ only on the boundary: a point exactly on the edge intersects but is not within. That distinction accounts for a small number of features in most datasets and for an enormous share of the confusion. Pick ",[186,508,415],{}," unless you have a reason, and use ",[186,511,436],{}," as a quick audit to list the points that fall outside your coverage.",[153,514,516],{"id":515},"summarise-in-the-other-direction","Summarise in the other direction",[14,518,519],{},"The reverse question — \"how many incidents in each ward?\" — keeps the polygons and adds counts. That is a different algorithm.",[179,521,523],{"className":181,"code":522,"language":183,"meta":184,"style":184},"processing.run(\"native:joinbylocationsummary\", {\n    \"INPUT\": \"\u002Fdata\u002Fwards.gpkg|layername=wards\",\n    \"JOIN\": \"\u002Fdata\u002Fincidents.gpkg|layername=incidents\",\n    \"PREDICATE\": [1],                      # contains\n    \"JOIN_FIELDS\": [\"severity\"],\n    \"SUMMARIES\": [0, 6],                   # count, mean\n    \"DISCARD_NONMATCHING\": False,\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fwards_incidents.gpkg\",\n})\n",[186,524,525,534,544,554,568,579,598,608,619],{"__ignoreMap":184},[189,526,527,529,532],{"class":191,"line":192},[189,528,213],{"class":199},[189,530,531],{"class":216},"\"native:joinbylocationsummary\"",[189,533,220],{"class":199},[189,535,536,538,540,542],{"class":191,"line":203},[189,537,226],{"class":216},[189,539,229],{"class":199},[189,541,246],{"class":216},[189,543,235],{"class":199},[189,545,546,548,550,552],{"class":191,"line":210},[189,547,241],{"class":216},[189,549,229],{"class":199},[189,551,232],{"class":216},[189,553,235],{"class":199},[189,555,556,558,560,562,565],{"class":191,"line":223},[189,557,254],{"class":216},[189,559,257],{"class":199},[189,561,298],{"class":260},[189,563,564],{"class":199},"],                      ",[189,566,567],{"class":266},"# contains\n",[189,569,570,572,574,577],{"class":191,"line":238},[189,571,273],{"class":216},[189,573,257],{"class":199},[189,575,576],{"class":216},"\"severity\"",[189,578,287],{"class":199},[189,580,581,584,586,588,590,592,595],{"class":191,"line":251},[189,582,583],{"class":216},"    \"SUMMARIES\"",[189,585,257],{"class":199},[189,587,46],{"class":260},[189,589,281],{"class":199},[189,591,112],{"class":260},[189,593,594],{"class":199},"],                   ",[189,596,597],{"class":266},"# count, mean\n",[189,599,600,602,604,606],{"class":191,"line":270},[189,601,310],{"class":216},[189,603,229],{"class":199},[189,605,315],{"class":260},[189,607,235],{"class":199},[189,609,610,612,614,617],{"class":191,"line":290},[189,611,336],{"class":216},[189,613,229],{"class":199},[189,615,616],{"class":216},"\"\u002Fdata\u002Foutput\u002Fwards_incidents.gpkg\"",[189,618,235],{"class":199},[189,620,621],{"class":191,"line":307},[189,622,349],{"class":199},[14,624,625,627,628,630,631,634,635,637,638,640,641,643,644,503,647,650,651,653,654,658],{},[164,626,354],{}," Here the polygons are the ",[186,629,358],{},", so the output has one row per ward with its geometry intact. ",[186,632,633],{},"SUMMARIES"," selects which statistics to compute for each field in ",[186,636,369],{}," — ",[186,639,46],{}," is count and ",[186,642,112],{}," is mean, and the output fields are named ",[186,645,646],{},"severity_count",[186,648,649],{},"severity_mean",". Wards with no incidents keep a count of zero rather than disappearing, which is what ",[186,652,377],{}," guarantees and what any subsequent ",[21,655,657],{"href":656},"\u002Fpyqgis-cartography-visualization\u002Fgraduated-categorized-renderers\u002Fcreate-choropleth-map-pyqgis\u002F","choropleth map"," needs in order to render every ward.",[14,660,661],{},[29,662,665,668,671,674,687,690,699,705,710,714,717,721,727,731,735,739,743,746,750,753,757,759,762],{"viewBox":663,"role":32,"ariaLabel":664,"xmlns":34},"0 0 760 258","Two directions of a spatial join: attributes joined onto points keeping point geometry, and a summary joined onto polygons keeping polygon geometry",[36,666,667],{},"Which layer keeps its geometry decides the algorithm",[40,669,670],{},"Joining attributes by location keeps the input layer's geometry, so points stay points and gain ward columns. Joining by location with a summary also keeps the input's geometry, but with polygons as the input the result is one row per polygon carrying counts and means of the points inside it.",[44,672],{"x":46,"y":46,"width":47,"height":673,"fill":49},"258",[675,676,677],"defs",{},[678,679,684],"marker",{"id":680,"viewBox":681,"refX":682,"refY":93,"markerWidth":490,"markerHeight":490,"orient":683},"sjArrow","0 0 10 10","8","auto-start-reverse",[60,685],{"d":686,"fill":130},"M0 0 L10 5 L0 10 z",[51,688,689],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"The INPUT layer is the one that survives",[44,691],{"x":692,"y":693,"width":694,"height":695,"rx":696,"fill":697,"stroke":63,"style":698},"16","48","356","188","10","#eff3ff","stroke-width:2.5",[51,700,704],{"x":701,"y":702,"style":703,"fill":63,"textAnchor":57},"194","74","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","joinattributesbylocation",[44,706],{"x":707,"y":708,"width":111,"height":707,"rx":112,"fill":138,"stroke":150,"style":709},"40","90","stroke-width:1.5",[51,711,713],{"x":116,"y":712,"style":129,"fill":130,"textAnchor":57},"115","INPUT: points",[44,715],{"x":716,"y":708,"width":111,"height":707,"rx":112,"fill":138,"stroke":150,"style":709},"208",[51,718,720],{"x":719,"y":712,"style":129,"fill":130,"textAnchor":57},"278","JOIN: wards",[44,722],{"x":707,"y":723,"width":724,"height":725,"rx":112,"fill":726,"stroke":94,"style":65},"152","308","56","#edf8e9",[51,728,730],{"x":701,"y":729,"style":129,"fill":130,"textAnchor":57},"176","one row per incident, geometry unchanged",[51,732,734],{"x":701,"y":733,"style":129,"fill":94,"textAnchor":57},"196","new columns: ward_code, ward_name",[44,736],{"x":737,"y":693,"width":694,"height":695,"rx":696,"fill":738,"stroke":69,"style":698},"388","#eef7f4",[51,740,742],{"x":741,"y":702,"style":703,"fill":69,"textAnchor":57},"566","joinbylocationsummary",[44,744],{"x":745,"y":708,"width":111,"height":707,"rx":112,"fill":138,"stroke":150,"style":709},"412",[51,747,749],{"x":748,"y":712,"style":129,"fill":130,"textAnchor":57},"482","INPUT: wards",[44,751],{"x":752,"y":708,"width":111,"height":707,"rx":112,"fill":138,"stroke":150,"style":709},"580",[51,754,756],{"x":755,"y":712,"style":129,"fill":130,"textAnchor":57},"650","JOIN: points",[44,758],{"x":745,"y":723,"width":724,"height":725,"rx":112,"fill":726,"stroke":94,"style":65},[51,760,761],{"x":741,"y":729,"style":129,"fill":130,"textAnchor":57},"one row per ward, geometry unchanged",[51,763,764],{"x":741,"y":733,"style":129,"fill":94,"textAnchor":57},"new columns: severity_count, severity_mean",[153,766,768],{"id":767},"audit-the-matches","Audit the matches",[14,770,771],{},"A spatial join that silently loses or duplicates rows is a data-quality problem wearing an algorithm's clothes. Check the counts.",[179,773,775],{"className":181,"code":774,"language":183,"meta":184,"style":184},"from qgis.core import QgsVectorLayer\n\npoints = QgsVectorLayer(\"\u002Fdata\u002Fincidents.gpkg|layername=incidents\", \"in\", \"ogr\")\njoined = QgsVectorLayer(\"\u002Fdata\u002Foutput\u002Fincidents_ward.gpkg|layername=incidents_ward\", \"out\", \"ogr\")\n\nprint(\"input\", points.featureCount(), \"output\", joined.featureCount())\n\nunmatched = [f[\"fid\"] for f in joined.getFeatures() if f[\"ward_code\"] is None]\nprint(f\"{len(unmatched)} points matched no ward\")\n",[186,776,777,790,794,820,843,847,867,871,918],{"__ignoreMap":184},[189,778,779,782,785,787],{"class":191,"line":192},[189,780,781],{"class":195},"from",[189,783,784],{"class":199}," qgis.core ",[189,786,196],{"class":195},[189,788,789],{"class":199}," QgsVectorLayer\n",[189,791,792],{"class":191,"line":203},[189,793,207],{"emptyLinePlaceholder":206},[189,795,796,799,802,805,807,809,812,814,817],{"class":191,"line":210},[189,797,798],{"class":199},"points ",[189,800,801],{"class":195},"=",[189,803,804],{"class":199}," QgsVectorLayer(",[189,806,232],{"class":216},[189,808,281],{"class":199},[189,810,811],{"class":216},"\"in\"",[189,813,281],{"class":199},[189,815,816],{"class":216},"\"ogr\"",[189,818,819],{"class":199},")\n",[189,821,822,825,827,829,832,834,837,839,841],{"class":191,"line":223},[189,823,824],{"class":199},"joined ",[189,826,801],{"class":195},[189,828,804],{"class":199},[189,830,831],{"class":216},"\"\u002Fdata\u002Foutput\u002Fincidents_ward.gpkg|layername=incidents_ward\"",[189,833,281],{"class":199},[189,835,836],{"class":216},"\"out\"",[189,838,281],{"class":199},[189,840,816],{"class":216},[189,842,819],{"class":199},[189,844,845],{"class":191,"line":238},[189,846,207],{"emptyLinePlaceholder":206},[189,848,849,852,855,858,861,864],{"class":191,"line":251},[189,850,851],{"class":260},"print",[189,853,854],{"class":199},"(",[189,856,857],{"class":216},"\"input\"",[189,859,860],{"class":199},", points.featureCount(), ",[189,862,863],{"class":216},"\"output\"",[189,865,866],{"class":199},", joined.featureCount())\n",[189,868,869],{"class":191,"line":270},[189,870,207],{"emptyLinePlaceholder":206},[189,872,873,876,878,881,884,887,890,893,896,899,902,905,907,909,912,915],{"class":191,"line":290},[189,874,875],{"class":199},"unmatched ",[189,877,801],{"class":195},[189,879,880],{"class":199}," [f[",[189,882,883],{"class":216},"\"fid\"",[189,885,886],{"class":199},"] ",[189,888,889],{"class":195},"for",[189,891,892],{"class":199}," f ",[189,894,895],{"class":195},"in",[189,897,898],{"class":199}," joined.getFeatures() ",[189,900,901],{"class":195},"if",[189,903,904],{"class":199}," f[",[189,906,278],{"class":216},[189,908,886],{"class":199},[189,910,911],{"class":195},"is",[189,913,914],{"class":260}," None",[189,916,917],{"class":199},"]\n",[189,919,920,922,924,927,930,933,936,939,942],{"class":191,"line":307},[189,921,851],{"class":260},[189,923,854],{"class":199},[189,925,926],{"class":195},"f",[189,928,929],{"class":216},"\"",[189,931,932],{"class":260},"{len",[189,934,935],{"class":199},"(unmatched)",[189,937,938],{"class":260},"}",[189,940,941],{"class":216}," points matched no ward\"",[189,943,819],{"class":199},[14,945,946,948,949,952,953,956,957,960,961,965],{},[164,947,354],{}," Equal counts with some nulls mean unmatched points were kept, which is the expected outcome of the parameters above. A larger output count means ",[186,950,951],{},"METHOD: 0"," allowed multiple matches — every boundary point produced one row per polygon it touched. A smaller count means ",[186,954,955],{},"DISCARD_NONMATCHING"," was ",[186,958,959],{},"True",". Printing this pair after every join takes two lines and removes an entire category of quiet errors, in the spirit of the plausibility checks in ",[21,962,964],{"href":963},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fhandle-errors-and-logging-in-unattended-scripts\u002F","Handle Errors and Logging in Unattended Scripts",".",[153,967,969],{"id":968},"do-it-in-the-database-when-the-data-lives-there","Do it in the database when the data lives there",[14,971,972],{},"If both layers are already in PostGIS, the join belongs there — one indexed query rather than two table reads and an in-memory match.",[179,974,978],{"className":975,"code":976,"language":977,"meta":184,"style":184},"language-sql shiki shiki-themes github-dark","SELECT i.id, i.geom, w.ward_code\nFROM public.incidents i\nLEFT JOIN public.wards w ON ST_Intersects(w.geom, i.geom)\n","sql",[186,979,980,985,990],{"__ignoreMap":184},[189,981,982],{"class":191,"line":192},[189,983,984],{},"SELECT i.id, i.geom, w.ward_code\n",[189,986,987],{"class":191,"line":203},[189,988,989],{},"FROM public.incidents i\n",[189,991,992],{"class":191,"line":210},[189,993,994],{},"LEFT JOIN public.wards w ON ST_Intersects(w.geom, i.geom)\n",[14,996,997,355,999,1002,1003,1006,1007,965],{},[164,998,354],{},[186,1000,1001],{},"LEFT JOIN"," is the SQL equivalent of keeping non-matching points, and PostGIS uses the GiST index on ",[186,1004,1005],{},"wards.geom"," to find candidates rather than testing every pair. Wrapped as a query layer it becomes an ordinary QGIS layer that is always current — see ",[21,1008,1010],{"href":1009},"\u002Fspatial-data-processing-automation\u002Fpostgis-and-database-workflows\u002Fload-postgis-query-layer-pyqgis\u002F","Load a PostGIS Query Layer in PyQGIS",[153,1012,1014],{"id":1013},"rescue-the-points-that-fall-just-outside","Rescue the points that fall just outside",[14,1016,1017],{},"Real point data misses. A GPS reading lands two metres into the sea, an address is geocoded to the centre of the road rather than the parcel, a boundary was digitised at a coarser scale than the points. Those features are not errors to discard — they belong somewhere obvious, and a nearest-feature join says where.",[179,1019,1021],{"className":181,"code":1020,"language":183,"meta":184,"style":184},"import processing\n\nprocessing.run(\"native:joinbynearest\", {\n    \"INPUT\": \"\u002Fdata\u002Foutput\u002Funmatched.gpkg|layername=unmatched\",\n    \"INPUT_2\": \"\u002Fdata\u002Fwards.gpkg|layername=wards\",\n    \"FIELDS_TO_COPY\": [\"ward_code\", \"ward_name\"],\n    \"DISCARD_NONMATCHING\": False,\n    \"PREFIX\": \"\",\n    \"NEIGHBORS\": 1,\n    \"MAX_DISTANCE\": 25,\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Funmatched_nearest.gpkg\",\n})\n",[186,1022,1023,1029,1033,1042,1053,1064,1079,1089,1099,1110,1122,1133],{"__ignoreMap":184},[189,1024,1025,1027],{"class":191,"line":192},[189,1026,196],{"class":195},[189,1028,200],{"class":199},[189,1030,1031],{"class":191,"line":203},[189,1032,207],{"emptyLinePlaceholder":206},[189,1034,1035,1037,1040],{"class":191,"line":210},[189,1036,213],{"class":199},[189,1038,1039],{"class":216},"\"native:joinbynearest\"",[189,1041,220],{"class":199},[189,1043,1044,1046,1048,1051],{"class":191,"line":223},[189,1045,226],{"class":216},[189,1047,229],{"class":199},[189,1049,1050],{"class":216},"\"\u002Fdata\u002Foutput\u002Funmatched.gpkg|layername=unmatched\"",[189,1052,235],{"class":199},[189,1054,1055,1058,1060,1062],{"class":191,"line":238},[189,1056,1057],{"class":216},"    \"INPUT_2\"",[189,1059,229],{"class":199},[189,1061,246],{"class":216},[189,1063,235],{"class":199},[189,1065,1066,1069,1071,1073,1075,1077],{"class":191,"line":251},[189,1067,1068],{"class":216},"    \"FIELDS_TO_COPY\"",[189,1070,257],{"class":199},[189,1072,278],{"class":216},[189,1074,281],{"class":199},[189,1076,284],{"class":216},[189,1078,287],{"class":199},[189,1080,1081,1083,1085,1087],{"class":191,"line":270},[189,1082,310],{"class":216},[189,1084,229],{"class":199},[189,1086,315],{"class":260},[189,1088,235],{"class":199},[189,1090,1091,1093,1095,1097],{"class":191,"line":290},[189,1092,323],{"class":216},[189,1094,229],{"class":199},[189,1096,328],{"class":216},[189,1098,235],{"class":199},[189,1100,1101,1104,1106,1108],{"class":191,"line":307},[189,1102,1103],{"class":216},"    \"NEIGHBORS\"",[189,1105,229],{"class":199},[189,1107,298],{"class":260},[189,1109,235],{"class":199},[189,1111,1112,1115,1117,1120],{"class":191,"line":320},[189,1113,1114],{"class":216},"    \"MAX_DISTANCE\"",[189,1116,229],{"class":199},[189,1118,1119],{"class":260},"25",[189,1121,235],{"class":199},[189,1123,1124,1126,1128,1131],{"class":191,"line":333},[189,1125,336],{"class":216},[189,1127,229],{"class":199},[189,1129,1130],{"class":216},"\"\u002Fdata\u002Foutput\u002Funmatched_nearest.gpkg\"",[189,1132,235],{"class":199},[189,1134,1135],{"class":191,"line":346},[189,1136,349],{"class":199},[14,1138,1139,355,1141,1144,1145,1148,1149,1152],{},[164,1140,354],{},[186,1142,1143],{},"MAX_DISTANCE"," is the honesty control: it caps how far a point may be from a polygon before the algorithm refuses to guess. Twenty-five metres might be reasonable for GPS drift against ward boundaries and absurd for parcel assignment, so the number is a decision about the data rather than a default to accept. ",[186,1146,1147],{},"NEIGHBORS: 1"," returns only the closest match. The output gains a ",[186,1150,1151],{},"distance"," field, and keeping it is what separates a defensible assignment from an invented one — a report can then state how many points were matched exactly and how many were snapped, with the largest distance involved.",[14,1154,1155],{},"Run this as a second pass over the unmatched points rather than instead of the containment join. Containment is exact and should win wherever it applies; nearest is a fallback whose results deserve to be labelled as such, so downstream analysis can exclude them if the question demands precision.",[14,1157,1158],{},[29,1159,1162,1165,1168,1170,1177,1180,1184,1189,1194,1197,1201,1205,1208,1211,1215,1219,1223,1226,1231,1235,1239],{"viewBox":1160,"role":32,"ariaLabel":1161,"xmlns":34},"0 0 760 250","A two-pass join: an exact containment pass assigning most points, then a nearest pass with a distance cap rescuing the ones that fell just outside, with a distance column recorded",[36,1163,1164],{},"Exact first, nearest second, distance recorded",[40,1166,1167],{},"The first pass assigns points by containment. The points that matched nothing go through a second pass that finds the nearest polygon within a capped distance and records how far away it was. Points beyond the cap remain unassigned rather than being guessed at.",[44,1169],{"x":46,"y":46,"width":47,"height":86,"fill":49},[675,1171,1172],{},[678,1173,1175],{"id":1174,"viewBox":681,"refX":682,"refY":93,"markerWidth":490,"markerHeight":490,"orient":683},"nearArrow",[60,1176],{"d":686,"fill":130},[51,1178,1179],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"A guess with a recorded distance is not the same as a match",[44,1181],{"x":692,"y":77,"width":1182,"height":1183,"rx":682,"fill":138,"stroke":150,"style":65},"180","66",[51,1185,1188],{"x":1186,"y":1187,"style":78,"fill":56,"textAnchor":57},"106","114","12 480 points",[51,1190,1193],{"x":1186,"y":1191,"style":1192,"fill":130,"textAnchor":57},"136","text-anchor:middle;font-size:10px;font-family:sans-serif","to assign",[44,1195],{"x":1196,"y":725,"width":107,"height":137,"rx":682,"fill":726,"stroke":94,"style":698},"240",[51,1198,1200],{"x":82,"y":1199,"style":78,"fill":94,"textAnchor":57},"80","contained — 12 431",[51,1202,1204],{"x":82,"y":1203,"style":1192,"fill":130,"textAnchor":57},"102","exact, distance = 0",[44,1206],{"x":1196,"y":1207,"width":107,"height":137,"rx":682,"fill":124,"stroke":73,"style":698},"128",[51,1209,1210],{"x":82,"y":723,"style":78,"fill":73,"textAnchor":57},"unmatched — 49",[51,1212,1214],{"x":82,"y":1213,"style":1192,"fill":130,"textAnchor":57},"174","second pass",[44,1216],{"x":1217,"y":1218,"width":1196,"height":137,"rx":682,"fill":697,"stroke":63,"style":698},"504","104",[51,1220,1222],{"x":1221,"y":1207,"style":78,"fill":63,"textAnchor":57},"624","nearest within 25 m — 44",[51,1224,1225],{"x":1221,"y":121,"style":1192,"fill":130,"textAnchor":57},"distance kept · 5 left unassigned",[191,1227],{"x1":733,"y1":1228,"x2":1229,"y2":77,"stroke":130,"style":1230},"108","234","stroke-width:2;marker-end:url(#nearArrow)",[191,1232],{"x1":733,"y1":1233,"x2":1229,"y2":1234,"stroke":130,"style":1230},"134","156",[191,1236],{"x1":1237,"y1":1234,"x2":1238,"y2":111,"stroke":130,"style":1230},"460","498",[51,1240,1242],{"x":53,"y":1241,"style":129,"fill":150,"textAnchor":57},"228","Keep the distance column so a later analysis can exclude the snapped points if it needs to",[153,1244,1246],{"id":1245},"qgis-version-compatibility","QGIS version compatibility",[14,1248,1249,1250,1252],{},"The examples target ",[164,1251,166],{}," (Python 3.12).",[387,1254,1255,1268],{},[390,1256,1257],{},[393,1258,1259,1262,1265],{},[396,1260,1261],{},"QGIS version",[396,1263,1264],{},"Python",[396,1266,1267],{},"Notes",[406,1269,1270,1281,1291,1302],{},[393,1271,1272,1275,1278],{},[411,1273,1274],{},"3.22 LTR",[411,1276,1277],{},"3.9",[411,1279,1280],{},"Both algorithms present; predicate codes identical.",[393,1282,1283,1286,1288],{},[411,1284,1285],{},"3.28 LTR",[411,1287,1277],{},[411,1289,1290],{},"Behaviour matches this page.",[393,1292,1293,1296,1299],{},[411,1294,1295],{},"3.34 LTR",[411,1297,1298],{},"3.12",[411,1300,1301],{},"Baseline for this page.",[393,1303,1304,1307,1309],{},[411,1305,1306],{},"3.40 \u002F 3.44",[411,1308,1298],{},[411,1310,1311],{},"Unchanged; the summary algorithm gained additional statistics.",[153,1313,1315],{"id":1314},"troubleshooting","Troubleshooting",[158,1317,1318,1324,1335,1345,1357,1367],{},[161,1319,1320,1323],{},[164,1321,1322],{},"Every point matched nothing."," The CRSs differ in a way the algorithm could not resolve, or the layers do not actually overlap. Compare extents first.",[161,1325,1326,355,1329,1331,1332,1334],{},[164,1327,1328],{},"The output has more rows than the input.",[186,1330,951],{}," allows multiple matches. Use ",[186,1333,373],{}," if one row per point is required.",[161,1336,1337,355,1340,1342,1343,965],{},[164,1338,1339],{},"Some points vanished.",[186,1341,955],{}," is ",[186,1344,959],{},[161,1346,1347,1350,1351,1353,1354,1356],{},[164,1348,1349],{},"Points on boundaries are assigned inconsistently."," With ",[186,1352,415],{}," and one-match-only, the winner is whichever polygon is visited first. Use ",[186,1355,482],{}," to exclude boundaries, or snap the points off the edges deliberately.",[161,1358,1359,1362,1363,965],{},[164,1360,1361],{},"The join is very slow."," The polygon layer has no spatial index, or the polygons are extremely detailed. Build an index, and consider simplifying — see ",[21,1364,1366],{"href":1365},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fsimplify-geometry-pyqgis\u002F","Simplify Geometry in PyQGIS",[161,1368,1369,1372,1373,1376],{},[164,1370,1371],{},"Joined field names have a prefix you did not ask for."," A name collided with an existing field. Set ",[186,1374,1375],{},"PREFIX"," explicitly so the collision is visible rather than automatic.",[153,1378,1380],{"id":1379},"conclusion","Conclusion",[14,1382,1383,1384,1387,1388,1390,1391,1393,1394,1397],{},"Point-in-polygon work is ",[186,1385,1386],{},"native:joinattributesbylocation"," with the ",[186,1389,415],{}," predicate, an explicit field list and ",[186,1392,373],{}," for one row per point; the reverse question is ",[186,1395,1396],{},"native:joinbylocationsummary"," with the polygons as input. Keep non-matching features so gaps are visible, compare the input and output counts every time, and push the join into PostGIS when both layers already live there.",[153,1399,1401],{"id":1400},"frequently-asked-questions","Frequently Asked Questions",[14,1403,1404,1407],{},[164,1405,1406],{},"Which layer should be the INPUT?","\nWhichever one you want the output to look like. The input keeps its geometry and its rows; the join layer only contributes attributes.",[14,1409,1410,1413,1414,1416,1417,1419],{},[164,1411,1412],{},"How do I find points that fall outside every polygon?","\nRun the join with ",[186,1415,377],{}," and filter for nulls, or run a separate join with the ",[186,1418,436],{}," predicate.",[14,1421,1422,1425,1426,1429],{},[164,1423,1424],{},"Can I join by nearest polygon instead of containment?","\nYes — ",[186,1427,1428],{},"native:joinbynearest"," attaches the closest feature and records the distance, which is the right tool for points that should have been inside but are not.",[14,1431,1432,1435],{},[164,1433,1434],{},"Does the join work across different CRSs?","\nThe algorithm reprojects the join layer to the input's CRS. It is still worth aligning them beforehand so the transformation is a deliberate, documented step.",[14,1437,1438,1441],{},[164,1439,1440],{},"Why do two points at the same location get different wards?","\nThey do not, unless the coordinates differ at a precision you cannot see. Print the coordinates to full precision before concluding the join is inconsistent.",[153,1443,1445],{"id":1444},"related","Related",[158,1447,1448,1453,1459,1465,1471],{},[161,1449,1450,1452],{},[21,1451,24],{"href":23}," — the guide this recipe belongs to",[161,1454,1455],{},[21,1456,1458],{"href":1457},"\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002Fdissolve-features-by-attribute-pyqgis\u002F","Dissolve Features by Attribute in PyQGIS",[161,1460,1461],{},[21,1462,1464],{"href":1463},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fjoin-attributes-by-field-value-pyqgis\u002F","Join Attributes by Field Value in PyQGIS",[161,1466,1467],{},[21,1468,1470],{"href":1469},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuild-spatial-index-pyqgis\u002F","Build a Spatial Index in PyQGIS",[161,1472,1473],{},[21,1474,1476],{"href":1475},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fcheck-geometry-intersects-pyqgis\u002F","Check Whether Geometries Intersect in PyQGIS",[1478,1479,1480],"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 pre.shiki code .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}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);}",{"title":184,"searchDepth":203,"depth":203,"links":1482},[1483,1484,1485,1486,1487,1488,1489,1490,1491,1492,1493,1494],{"id":155,"depth":203,"text":156},{"id":176,"depth":203,"text":177},{"id":381,"depth":203,"text":382},{"id":515,"depth":203,"text":516},{"id":767,"depth":203,"text":768},{"id":968,"depth":203,"text":969},{"id":1013,"depth":203,"text":1014},{"id":1245,"depth":203,"text":1246},{"id":1314,"depth":203,"text":1315},{"id":1379,"depth":203,"text":1380},{"id":1400,"depth":203,"text":1401},{"id":1444,"depth":203,"text":1445},"Attach polygon attributes to points with native:joinattributesbylocation, choose the right predicate, summarise many-to-one matches, and handle points that match nothing or match twice.","md",{"slug":1498,"type":1499,"breadcrumb":1500,"datePublished":1501,"dateModified":1501},"spatial-join-points-in-polygons-pyqgis","article","Spatial Join","2026-08-10","\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002Fspatial-join-points-in-polygons-pyqgis",{"title":5,"description":1495},"spatial-data-processing-automation\u002Fvector-data-manipulation\u002Fspatial-join-points-in-polygons-pyqgis\u002Findex","wNJhUAB8iaKS-A9vCPYOptZImjKAEplehu_snOXm_bU",1786401338560]