[{"data":1,"prerenderedAt":1876},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fjoin-attributes-by-field-value-pyqgis":3},{"id":4,"title":5,"body":6,"description":1865,"extension":1866,"meta":1867,"navigation":258,"path":1872,"seo":1873,"stem":1874,"__hash__":1875},"docs\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fjoin-attributes-by-field-value-pyqgis\u002Findex.md","Join Attributes by Field Value in PyQGIS",{"type":7,"value":8,"toc":1853},"minimark",[9,13,22,31,192,197,219,223,226,520,547,550,653,671,675,681,849,878,881,967,972,976,982,1088,1204,1220,1224,1230,1356,1359,1568,1589,1593,1599,1661,1667,1671,1747,1751,1763,1767,1776,1782,1799,1809,1822,1826,1849],[10,11,5],"h1",{"id":12},"join-attributes-by-field-value-in-pyqgis",[14,15,16,17,21],"p",{},"Attaching a spreadsheet of measurements to a set of polygons is one of the most common GIS tasks there is, and it fails the same way for nearly everyone the first time: the join runs, every joined column is ",[18,19,20],"code",{},"NULL",", and nothing in the output says why. The cause is almost never the join — it is that one side stores the key as an integer and the other loaded it as text, so no two values are ever equal.",[14,23,24,25,30],{},"This page is a focused recipe within ",[26,27,29],"a",{"href":28},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002F","Attribute Tables and Field Management in PyQGIS",". It covers the two kinds of join QGIS offers, diagnosing key mismatches before running anything, handling one-to-many relationships, and choosing which form belongs in a pipeline.",[14,32,33],{},[34,35,40,44,48,55,72,81,91,97,103,125,129,133,136,150,169,188],"svg",{"viewBox":36,"role":37,"ariaLabel":38,"xmlns":39},"0 0 760 262","img","A key mismatch between an integer parcel identifier and a text one, showing that the values look identical when printed but never compare equal","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[41,42,43],"title",{},"Why an all-NULL join is almost always a key-type problem",[45,46,47],"desc",{},"The parcels layer stores parcel identifiers as integers. The CSV loaded them as text, and one value additionally carries a leading zero. Printed side by side the values look almost the same, but the equality test between an integer and a string is false for every row, so every joined column comes back empty.",[49,50],"rect",{"x":51,"y":51,"width":52,"height":53,"fill":54},"0","760","262","#f6f3ea",[56,57,58],"defs",{},[59,60,67],"marker",{"id":61,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"joinKeyArrow","0 0 10 10","8","5","7","auto-start-reverse",[68,69],"path",{"d":70,"fill":71},"M0 0 L10 5 L0 10 z","#b91c1c",[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","They print the same. They do not compare equal.",[49,82],{"x":83,"y":84,"width":85,"height":86,"rx":87,"fill":88,"stroke":89,"style":90},"16","46","240","176","10","#fffdf7","#2563eb","stroke-width:2.5",[73,92,96],{"x":93,"y":94,"style":95,"fill":89,"textAnchor":79},"136","70","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","parcels · parcel_id",[73,98,102],{"x":93,"y":99,"style":100,"fill":101,"textAnchor":79},"90","text-anchor:middle;font-size:11px;font-family:sans-serif","#59645f","Integer",[104,105,107,113,117,121],"g",{"style":106},"font-size:12px;font-family:monospace",[73,108,112],{"x":93,"y":109,"style":110,"fill":111,"textAnchor":79},"122","text-anchor:middle","#2f3b35","1041",[73,114,116],{"x":93,"y":115,"style":110,"fill":111,"textAnchor":79},"150","1042",[73,118,120],{"x":93,"y":119,"style":110,"fill":111,"textAnchor":79},"178","1043",[73,122,124],{"x":93,"y":123,"style":110,"fill":111,"textAnchor":79},"206","1044",[49,126],{"x":127,"y":84,"width":85,"height":86,"rx":87,"fill":88,"stroke":128,"style":90},"504","#b45309",[73,130,132],{"x":131,"y":94,"style":95,"fill":128,"textAnchor":79},"624","valuations.csv · parcel_id",[73,134,135],{"x":131,"y":99,"style":100,"fill":101,"textAnchor":79},"String",[104,137,138,141,144,147],{"style":106},[73,139,140],{"x":131,"y":109,"style":110,"fill":111,"textAnchor":79},"\"1041\"",[73,142,143],{"x":131,"y":115,"style":110,"fill":111,"textAnchor":79},"\"1042\"",[73,145,146],{"x":131,"y":119,"style":110,"fill":111,"textAnchor":79},"\"01043\"",[73,148,149],{"x":131,"y":123,"style":110,"fill":111,"textAnchor":79},"\"1044 \"",[104,151,153,160,163,166],{"stroke":71,"style":152},"stroke-width:2;stroke-dasharray:6 4",[154,155],"line",{"x1":156,"y1":157,"x2":158,"y2":157,"markerEnd":159},"256","118","500","url(#joinKeyArrow)",[154,161],{"x1":156,"y1":162,"x2":158,"y2":162,"markerEnd":159},"146",[154,164],{"x1":156,"y1":165,"x2":158,"y2":165,"markerEnd":159},"174",[154,167],{"x1":156,"y1":168,"x2":158,"y2":168,"markerEnd":159},"202",[104,170,172,177,180,184],{"style":171},"font-size:13px;font-weight:bold;font-family:sans-serif;text-anchor:middle",[73,173,176],{"x":174,"y":175,"fill":71},"378","114","✗ type",[73,178,176],{"x":174,"y":179,"fill":71},"142",[73,181,183],{"x":174,"y":182,"fill":71},"170","✗ padding",[73,185,187],{"x":174,"y":186,"fill":71},"198","✗ whitespace",[73,189,191],{"x":75,"y":190,"style":100,"fill":101,"textAnchor":79},"246","four rows, four different reasons, one identical symptom",[193,194,196],"h2",{"id":195},"prerequisites","Prerequisites",[198,199,200,208,211],"ul",{},[201,202,203,207],"li",{},[204,205,206],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer.",[201,209,210],{},"A target vector layer and a source table — another vector layer, a CSV, or a database table.",[201,212,213,214,218],{},"A key field present on both. It does not have to have the same ",[215,216,217],"em",{},"name",", but the values must genuinely match.",[193,220,222],{"id":221},"diagnose-the-key-first","Diagnose the key first",[14,224,225],{},"Ten seconds of checking saves an hour of confusion. Compare the types and a sample of values before running any join.",[227,228,233],"pre",{"className":229,"code":230,"language":231,"meta":232,"style":232},"language-python shiki shiki-themes github-dark","from qgis.core import QgsProject\n\nparcels = QgsProject.instance().mapLayersByName(\"parcels\")[0]\nvalues = QgsProject.instance().mapLayersByName(\"valuations\")[0]\n\n\ndef describe(layer, field):\n    index = layer.fields().indexOf(field)\n    if index == -1:\n        return f\"{layer.name()}: no field '{field}'\"\n    qfield = layer.fields()[index]\n    sample = [f[field] for f in layer.getFeatures()][:4]\n    return f\"{layer.name()}.{field}: {qfield.typeName()} -> {sample!r}\"\n\n\nprint(describe(parcels, \"parcel_id\"))\nprint(describe(values, \"parcel_id\"))\n","python","",[18,234,235,253,260,285,304,309,314,327,338,359,393,404,432,483,488,493,508],{"__ignoreMap":232},[236,237,239,243,247,250],"span",{"class":154,"line":238},1,[236,240,242],{"class":241},"snl16","from",[236,244,246],{"class":245},"s95oV"," qgis.core ",[236,248,249],{"class":241},"import",[236,251,252],{"class":245}," QgsProject\n",[236,254,256],{"class":154,"line":255},2,[236,257,259],{"emptyLinePlaceholder":258},true,"\n",[236,261,263,266,269,272,276,279,282],{"class":154,"line":262},3,[236,264,265],{"class":245},"parcels ",[236,267,268],{"class":241},"=",[236,270,271],{"class":245}," QgsProject.instance().mapLayersByName(",[236,273,275],{"class":274},"sU2Wk","\"parcels\"",[236,277,278],{"class":245},")[",[236,280,51],{"class":281},"sDLfK",[236,283,284],{"class":245},"]\n",[236,286,288,291,293,295,298,300,302],{"class":154,"line":287},4,[236,289,290],{"class":245},"values ",[236,292,268],{"class":241},[236,294,271],{"class":245},[236,296,297],{"class":274},"\"valuations\"",[236,299,278],{"class":245},[236,301,51],{"class":281},[236,303,284],{"class":245},[236,305,307],{"class":154,"line":306},5,[236,308,259],{"emptyLinePlaceholder":258},[236,310,312],{"class":154,"line":311},6,[236,313,259],{"emptyLinePlaceholder":258},[236,315,317,320,324],{"class":154,"line":316},7,[236,318,319],{"class":241},"def",[236,321,323],{"class":322},"svObZ"," describe",[236,325,326],{"class":245},"(layer, field):\n",[236,328,330,333,335],{"class":154,"line":329},8,[236,331,332],{"class":245},"    index ",[236,334,268],{"class":241},[236,336,337],{"class":245}," layer.fields().indexOf(field)\n",[236,339,341,344,347,350,353,356],{"class":154,"line":340},9,[236,342,343],{"class":241},"    if",[236,345,346],{"class":245}," index ",[236,348,349],{"class":241},"==",[236,351,352],{"class":241}," -",[236,354,355],{"class":281},"1",[236,357,358],{"class":245},":\n",[236,360,362,365,368,371,374,377,380,383,385,388,390],{"class":154,"line":361},10,[236,363,364],{"class":241},"        return",[236,366,367],{"class":241}," f",[236,369,370],{"class":274},"\"",[236,372,373],{"class":281},"{",[236,375,376],{"class":245},"layer.name()",[236,378,379],{"class":281},"}",[236,381,382],{"class":274},": no field '",[236,384,373],{"class":281},[236,386,387],{"class":245},"field",[236,389,379],{"class":281},[236,391,392],{"class":274},"'\"\n",[236,394,396,399,401],{"class":154,"line":395},11,[236,397,398],{"class":245},"    qfield ",[236,400,268],{"class":241},[236,402,403],{"class":245}," layer.fields()[index]\n",[236,405,407,410,412,415,418,421,424,427,430],{"class":154,"line":406},12,[236,408,409],{"class":245},"    sample ",[236,411,268],{"class":241},[236,413,414],{"class":245}," [f[field] ",[236,416,417],{"class":241},"for",[236,419,420],{"class":245}," f ",[236,422,423],{"class":241},"in",[236,425,426],{"class":245}," layer.getFeatures()][:",[236,428,429],{"class":281},"4",[236,431,284],{"class":245},[236,433,435,438,440,442,444,446,448,451,453,455,457,460,462,465,467,470,472,475,478,480],{"class":154,"line":434},13,[236,436,437],{"class":241},"    return",[236,439,367],{"class":241},[236,441,370],{"class":274},[236,443,373],{"class":281},[236,445,376],{"class":245},[236,447,379],{"class":281},[236,449,450],{"class":274},".",[236,452,373],{"class":281},[236,454,387],{"class":245},[236,456,379],{"class":281},[236,458,459],{"class":274},": ",[236,461,373],{"class":281},[236,463,464],{"class":245},"qfield.typeName()",[236,466,379],{"class":281},[236,468,469],{"class":274}," -> ",[236,471,373],{"class":281},[236,473,474],{"class":245},"sample",[236,476,477],{"class":241},"!r",[236,479,379],{"class":281},[236,481,482],{"class":274},"\"\n",[236,484,486],{"class":154,"line":485},14,[236,487,259],{"emptyLinePlaceholder":258},[236,489,491],{"class":154,"line":490},15,[236,492,259],{"emptyLinePlaceholder":258},[236,494,496,499,502,505],{"class":154,"line":495},16,[236,497,498],{"class":281},"print",[236,500,501],{"class":245},"(describe(parcels, ",[236,503,504],{"class":274},"\"parcel_id\"",[236,506,507],{"class":245},"))\n",[236,509,511,513,516,518],{"class":154,"line":510},17,[236,512,498],{"class":281},[236,514,515],{"class":245},"(describe(values, ",[236,517,504],{"class":274},[236,519,507],{"class":245},[14,521,522,525,526,529,530,533,534,533,536,539,540,542,543,546],{},[204,523,524],{},"Breakdown:"," ",[18,527,528],{},"typeName()"," reports the provider's own type name — ",[18,531,532],{},"Integer64",", ",[18,535,135],{},[18,537,538],{},"Real"," — which is the single most informative thing you can print here. The ",[18,541,477],{}," in the f-string is deliberate: it shows quotes around strings and reveals trailing whitespace, both invisible with plain ",[18,544,545],{},"str()",". A sample of four rows is enough to spot padding, case differences and stray spaces.",[14,548,549],{},"If the types differ, cast one side before joining rather than hoping the join coerces:",[227,551,553],{"className":229,"code":552,"language":231,"meta":232,"style":232},"import processing\n\nvalues_cast = processing.run(\"native:fieldcalculator\", {\n    \"INPUT\": values,\n    \"FIELD_NAME\": \"parcel_key\",\n    \"FIELD_TYPE\": 1,            # 1 = integer\n    \"FORMULA\": 'to_int(trim(\"parcel_id\"))',\n    \"OUTPUT\": \"TEMPORARY_OUTPUT\",\n})[\"OUTPUT\"]\n",[18,554,555,562,566,582,590,603,619,631,643],{"__ignoreMap":232},[236,556,557,559],{"class":154,"line":238},[236,558,249],{"class":241},[236,560,561],{"class":245}," processing\n",[236,563,564],{"class":154,"line":255},[236,565,259],{"emptyLinePlaceholder":258},[236,567,568,571,573,576,579],{"class":154,"line":262},[236,569,570],{"class":245},"values_cast ",[236,572,268],{"class":241},[236,574,575],{"class":245}," processing.run(",[236,577,578],{"class":274},"\"native:fieldcalculator\"",[236,580,581],{"class":245},", {\n",[236,583,584,587],{"class":154,"line":287},[236,585,586],{"class":274},"    \"INPUT\"",[236,588,589],{"class":245},": values,\n",[236,591,592,595,597,600],{"class":154,"line":306},[236,593,594],{"class":274},"    \"FIELD_NAME\"",[236,596,459],{"class":245},[236,598,599],{"class":274},"\"parcel_key\"",[236,601,602],{"class":245},",\n",[236,604,605,608,610,612,615],{"class":154,"line":311},[236,606,607],{"class":274},"    \"FIELD_TYPE\"",[236,609,459],{"class":245},[236,611,355],{"class":281},[236,613,614],{"class":245},",            ",[236,616,618],{"class":617},"sjoCn","# 1 = integer\n",[236,620,621,624,626,629],{"class":154,"line":316},[236,622,623],{"class":274},"    \"FORMULA\"",[236,625,459],{"class":245},[236,627,628],{"class":274},"'to_int(trim(\"parcel_id\"))'",[236,630,602],{"class":245},[236,632,633,636,638,641],{"class":154,"line":329},[236,634,635],{"class":274},"    \"OUTPUT\"",[236,637,459],{"class":245},[236,639,640],{"class":274},"\"TEMPORARY_OUTPUT\"",[236,642,602],{"class":245},[236,644,645,648,651],{"class":154,"line":340},[236,646,647],{"class":245},"})[",[236,649,650],{"class":274},"\"OUTPUT\"",[236,652,284],{"class":245},[14,654,655,525,657,660,661,663,664,666,667,670],{},[204,656,524],{},[18,658,659],{},"to_int()"," converts the text to a number, which also normalises leading zeros — ",[18,662,146],{}," and ",[18,665,120],{}," become the same value. ",[18,668,669],{},"trim()"," strips the whitespace that spreadsheet exports leave behind. Creating a new key field rather than overwriting the original keeps the source auditable, and gives you something to point at if the join still comes up empty.",[193,672,674],{"id":673},"the-materialised-join","The materialised join",[14,676,677,680],{},[18,678,679],{},"native:joinattributestable"," copies the matched values into a new layer. The columns are real, writable and permanent, which is what you want for anything you will hand on.",[227,682,684],{"className":229,"code":683,"language":231,"meta":232,"style":232},"import processing\n\njoined = processing.run(\"native:joinattributestable\", {\n    \"INPUT\": \"\u002Fdata\u002Fparcels.gpkg|layername=parcels\",\n    \"FIELD\": \"parcel_id\",\n    \"INPUT_2\": \"\u002Fdata\u002Fvaluations.csv\",\n    \"FIELD_2\": \"parcel_id\",\n    \"FIELDS_TO_COPY\": [\"assessed\", \"survey_year\"],\n    \"METHOD\": 1,                 # 1 = first matching feature only\n    \"DISCARD_NONMATCHING\": False,\n    \"PREFIX\": \"val_\",\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fparcels_valued.gpkg\",\n})[\"OUTPUT\"]\n\nprint(joined.featureCount(), \"feature(s) in the output\")\n",[18,685,686,692,696,710,721,732,744,755,774,789,801,813,824,832,836],{"__ignoreMap":232},[236,687,688,690],{"class":154,"line":238},[236,689,249],{"class":241},[236,691,561],{"class":245},[236,693,694],{"class":154,"line":255},[236,695,259],{"emptyLinePlaceholder":258},[236,697,698,701,703,705,708],{"class":154,"line":262},[236,699,700],{"class":245},"joined ",[236,702,268],{"class":241},[236,704,575],{"class":245},[236,706,707],{"class":274},"\"native:joinattributestable\"",[236,709,581],{"class":245},[236,711,712,714,716,719],{"class":154,"line":287},[236,713,586],{"class":274},[236,715,459],{"class":245},[236,717,718],{"class":274},"\"\u002Fdata\u002Fparcels.gpkg|layername=parcels\"",[236,720,602],{"class":245},[236,722,723,726,728,730],{"class":154,"line":306},[236,724,725],{"class":274},"    \"FIELD\"",[236,727,459],{"class":245},[236,729,504],{"class":274},[236,731,602],{"class":245},[236,733,734,737,739,742],{"class":154,"line":311},[236,735,736],{"class":274},"    \"INPUT_2\"",[236,738,459],{"class":245},[236,740,741],{"class":274},"\"\u002Fdata\u002Fvaluations.csv\"",[236,743,602],{"class":245},[236,745,746,749,751,753],{"class":154,"line":316},[236,747,748],{"class":274},"    \"FIELD_2\"",[236,750,459],{"class":245},[236,752,504],{"class":274},[236,754,602],{"class":245},[236,756,757,760,763,766,768,771],{"class":154,"line":329},[236,758,759],{"class":274},"    \"FIELDS_TO_COPY\"",[236,761,762],{"class":245},": [",[236,764,765],{"class":274},"\"assessed\"",[236,767,533],{"class":245},[236,769,770],{"class":274},"\"survey_year\"",[236,772,773],{"class":245},"],\n",[236,775,776,779,781,783,786],{"class":154,"line":340},[236,777,778],{"class":274},"    \"METHOD\"",[236,780,459],{"class":245},[236,782,355],{"class":281},[236,784,785],{"class":245},",                 ",[236,787,788],{"class":617},"# 1 = first matching feature only\n",[236,790,791,794,796,799],{"class":154,"line":361},[236,792,793],{"class":274},"    \"DISCARD_NONMATCHING\"",[236,795,459],{"class":245},[236,797,798],{"class":281},"False",[236,800,602],{"class":245},[236,802,803,806,808,811],{"class":154,"line":395},[236,804,805],{"class":274},"    \"PREFIX\"",[236,807,459],{"class":245},[236,809,810],{"class":274},"\"val_\"",[236,812,602],{"class":245},[236,814,815,817,819,822],{"class":154,"line":406},[236,816,635],{"class":274},[236,818,459],{"class":245},[236,820,821],{"class":274},"\"\u002Fdata\u002Foutput\u002Fparcels_valued.gpkg\"",[236,823,602],{"class":245},[236,825,826,828,830],{"class":154,"line":434},[236,827,647],{"class":245},[236,829,650],{"class":274},[236,831,284],{"class":245},[236,833,834],{"class":154,"line":485},[236,835,259],{"emptyLinePlaceholder":258},[236,837,838,840,843,846],{"class":154,"line":490},[236,839,498],{"class":281},[236,841,842],{"class":245},"(joined.featureCount(), ",[236,844,845],{"class":274},"\"feature(s) in the output\"",[236,847,848],{"class":245},")\n",[14,850,851,525,853,856,857,860,861,863,864,866,867,870,871,873,874,877],{},[204,852,524],{},[18,854,855],{},"FIELDS_TO_COPY"," restricts what comes across; leaving it empty copies everything, which usually drags in duplicate key columns. ",[18,858,859],{},"METHOD"," decides one-to-many behaviour — ",[18,862,355],{}," keeps only the first match, ",[18,865,51],{}," creates one output feature per match. ",[18,868,869],{},"DISCARD_NONMATCHING: False"," keeps unmatched parcels with ",[18,872,20],{}," in the joined columns, which is what makes the result auditable: comparing the output count with the input count tells you whether anything was dropped. ",[18,875,876],{},"PREFIX"," avoids column-name collisions.",[14,879,880],{},"Auditing the match rate immediately is worth the three lines:",[227,882,884],{"className":229,"code":883,"language":231,"meta":232,"style":232},"matched = sum(1 for f in joined.getFeatures() if f[\"val_assessed\"] is not None)\nprint(f\"{matched}\u002F{joined.featureCount()} matched\")\n",[18,885,886,934],{"__ignoreMap":232},[236,887,888,891,893,896,899,901,904,906,908,911,914,917,920,923,926,929,932],{"class":154,"line":238},[236,889,890],{"class":245},"matched ",[236,892,268],{"class":241},[236,894,895],{"class":281}," sum",[236,897,898],{"class":245},"(",[236,900,355],{"class":281},[236,902,903],{"class":241}," for",[236,905,420],{"class":245},[236,907,423],{"class":241},[236,909,910],{"class":245}," joined.getFeatures() ",[236,912,913],{"class":241},"if",[236,915,916],{"class":245}," f[",[236,918,919],{"class":274},"\"val_assessed\"",[236,921,922],{"class":245},"] ",[236,924,925],{"class":241},"is",[236,927,928],{"class":241}," not",[236,930,931],{"class":281}," None",[236,933,848],{"class":245},[236,935,936,938,940,943,945,947,950,952,955,957,960,962,965],{"class":154,"line":255},[236,937,498],{"class":281},[236,939,898],{"class":245},[236,941,942],{"class":241},"f",[236,944,370],{"class":274},[236,946,373],{"class":281},[236,948,949],{"class":245},"matched",[236,951,379],{"class":281},[236,953,954],{"class":274},"\u002F",[236,956,373],{"class":281},[236,958,959],{"class":245},"joined.featureCount()",[236,961,379],{"class":281},[236,963,964],{"class":274}," matched\"",[236,966,848],{"class":245},[14,968,969,971],{},[204,970,524],{}," A match rate of zero means a key problem. A rate of exactly the source table's row count means the join worked but the source is incomplete. Anything in between is normal and worth recording. This check belongs in every scripted join.",[193,973,975],{"id":974},"the-virtual-join","The virtual join",[14,977,978,981],{},[18,979,980],{},"addJoin()"," attaches the table as a live view. Nothing is copied; QGIS re-reads the source whenever it needs the values.",[14,983,984],{},[34,985,988,991,994,997,1004,1007,1010,1014,1022,1027,1031,1036,1040,1044,1049,1052,1055,1059,1063,1067,1071,1074,1077,1080,1082,1085],{"viewBox":986,"role":37,"ariaLabel":987,"xmlns":39},"0 0 760 244","A virtual join keeps a live dependency on the source file, while a materialised join produces a self-contained output that survives the source being moved",[41,989,990],{},"What happens when the source file moves",[45,992,993],{},"On the left a project holds a virtual join to a CSV. When the CSV is moved or deleted the joined columns become empty and the project shows missing values. On the right a materialised join has already copied the values into the output layer, so moving or deleting the CSV has no effect at all.",[49,995],{"x":51,"y":51,"width":52,"height":996,"fill":54},"244",[56,998,999],{},[59,1000,1002],{"id":1001,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"joinLiveArrow",[68,1003],{"d":70,"fill":111},[73,1005,1006],{"x":75,"y":76,"style":77,"fill":78,"textAnchor":79},"A virtual join is a dependency; a materialised join is a snapshot",[49,1008],{"x":83,"y":84,"width":1009,"height":86,"rx":87,"fill":88,"stroke":89,"style":90},"356",[73,1011,1013],{"x":1012,"y":94,"style":95,"fill":89,"textAnchor":79},"194","layer.addJoin() — live",[49,1015],{"x":1016,"y":1017,"width":1018,"height":1019,"rx":1020,"fill":88,"stroke":101,"style":1021},"44","92","120","56","6","stroke-width:2",[73,1023,1026],{"x":1024,"y":1025,"style":100,"fill":111,"textAnchor":79},"104","116","project",[73,1028,1030],{"x":1024,"y":1029,"style":100,"fill":111,"textAnchor":79},"134","parcels + join",[49,1032],{"x":1033,"y":1017,"width":1018,"height":1019,"rx":1020,"fill":71,"fillOpacity":1034,"stroke":71,"style":1035},"224",0.12,"stroke-width:2;stroke-dasharray:5 4",[73,1037,1039],{"x":1038,"y":1025,"style":100,"fill":111,"textAnchor":79},"284","valuations.csv",[73,1041,1043],{"x":1038,"y":1029,"style":1042,"fill":71,"textAnchor":79},"text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","moved away",[154,1045],{"x1":1046,"y1":1018,"x2":1047,"y2":1018,"stroke":71,"style":1048},"164","220","stroke-width:2;stroke-dasharray:5 4;marker-end:url(#joinLiveArrow)",[73,1050,1051],{"x":1012,"y":119,"style":100,"fill":71,"textAnchor":79},"joined columns go empty",[73,1053,1054],{"x":1012,"y":186,"style":100,"fill":111,"textAnchor":79},"read-only, prefixed, re-read on every draw",[49,1056],{"x":1057,"y":84,"width":1009,"height":86,"rx":87,"fill":88,"stroke":1058,"style":90},"388","#15803d",[73,1060,1062],{"x":1061,"y":94,"style":95,"fill":1058,"textAnchor":79},"566","joinattributestable — copied",[49,1064],{"x":1065,"y":1017,"width":1018,"height":1019,"rx":1020,"fill":1058,"fillOpacity":1066,"stroke":1058,"style":1021},"416",0.14,[73,1068,1070],{"x":1069,"y":1025,"style":100,"fill":111,"textAnchor":79},"476","parcels_valued",[73,1072,1073],{"x":1069,"y":1029,"style":100,"fill":111,"textAnchor":79},"values inside",[49,1075],{"x":1076,"y":1017,"width":1018,"height":1019,"rx":1020,"fill":88,"stroke":101,"style":1035},"596",[73,1078,1039],{"x":1079,"y":1025,"style":100,"fill":111,"textAnchor":79},"656",[73,1081,1043],{"x":1079,"y":1029,"style":100,"fill":101,"textAnchor":79},[73,1083,1084],{"x":1061,"y":119,"style":100,"fill":1058,"textAnchor":79},"output unaffected",[73,1086,1087],{"x":1061,"y":186,"style":100,"fill":111,"textAnchor":79},"writable, self-contained, safe to send",[227,1089,1091],{"className":229,"code":1090,"language":231,"meta":232,"style":232},"from qgis.core import QgsProject, QgsVectorLayerJoinInfo\n\njoin = QgsVectorLayerJoinInfo()\njoin.setJoinLayerId(values.id())\njoin.setJoinLayer(values)\njoin.setJoinFieldName(\"parcel_id\")\njoin.setTargetFieldName(\"parcel_id\")\njoin.setJoinFieldNamesSubset([\"assessed\", \"survey_year\"])\njoin.setPrefix(\"val_\")\njoin.setUsingMemoryCache(True)\n\nparcels.addJoin(join)\nprint([f.name() for f in parcels.fields()])\n",[18,1092,1093,1104,1108,1118,1123,1128,1137,1146,1160,1169,1179,1183,1188],{"__ignoreMap":232},[236,1094,1095,1097,1099,1101],{"class":154,"line":238},[236,1096,242],{"class":241},[236,1098,246],{"class":245},[236,1100,249],{"class":241},[236,1102,1103],{"class":245}," QgsProject, QgsVectorLayerJoinInfo\n",[236,1105,1106],{"class":154,"line":255},[236,1107,259],{"emptyLinePlaceholder":258},[236,1109,1110,1113,1115],{"class":154,"line":262},[236,1111,1112],{"class":245},"join ",[236,1114,268],{"class":241},[236,1116,1117],{"class":245}," QgsVectorLayerJoinInfo()\n",[236,1119,1120],{"class":154,"line":287},[236,1121,1122],{"class":245},"join.setJoinLayerId(values.id())\n",[236,1124,1125],{"class":154,"line":306},[236,1126,1127],{"class":245},"join.setJoinLayer(values)\n",[236,1129,1130,1133,1135],{"class":154,"line":311},[236,1131,1132],{"class":245},"join.setJoinFieldName(",[236,1134,504],{"class":274},[236,1136,848],{"class":245},[236,1138,1139,1142,1144],{"class":154,"line":316},[236,1140,1141],{"class":245},"join.setTargetFieldName(",[236,1143,504],{"class":274},[236,1145,848],{"class":245},[236,1147,1148,1151,1153,1155,1157],{"class":154,"line":329},[236,1149,1150],{"class":245},"join.setJoinFieldNamesSubset([",[236,1152,765],{"class":274},[236,1154,533],{"class":245},[236,1156,770],{"class":274},[236,1158,1159],{"class":245},"])\n",[236,1161,1162,1165,1167],{"class":154,"line":340},[236,1163,1164],{"class":245},"join.setPrefix(",[236,1166,810],{"class":274},[236,1168,848],{"class":245},[236,1170,1171,1174,1177],{"class":154,"line":361},[236,1172,1173],{"class":245},"join.setUsingMemoryCache(",[236,1175,1176],{"class":281},"True",[236,1178,848],{"class":245},[236,1180,1181],{"class":154,"line":395},[236,1182,259],{"emptyLinePlaceholder":258},[236,1184,1185],{"class":154,"line":406},[236,1186,1187],{"class":245},"parcels.addJoin(join)\n",[236,1189,1190,1192,1195,1197,1199,1201],{"class":154,"line":434},[236,1191,498],{"class":281},[236,1193,1194],{"class":245},"([f.name() ",[236,1196,417],{"class":241},[236,1198,420],{"class":245},[236,1200,423],{"class":241},[236,1202,1203],{"class":245}," parcels.fields()])\n",[14,1205,1206,525,1208,1211,1212,1215,1216,1219],{},[204,1207,524],{},[18,1209,1210],{},"setUsingMemoryCache(True)"," loads the join table into memory once, which makes rendering and iteration far faster at the cost of not seeing later edits to the source. ",[18,1213,1214],{},"setJoinFieldNamesSubset()"," limits which columns appear, avoiding a duplicated key column. ",[18,1217,1218],{},"setPrefix()"," controls the naming — without it QGIS uses the join layer's name, which is rarely what you want in an expression. The joined columns are read-only and disappear the moment the join is removed or the source file cannot be found, which is why a virtual join belongs in an interactive project rather than a pipeline.",[193,1221,1223],{"id":1222},"handle-one-to-many-properly","Handle one-to-many properly",[14,1225,1226,1227,1229],{},"When one parcel matches several rows in the source table, there is no single right answer — there are three, and the algorithm's ",[18,1228,859],{}," parameter only offers two of them.",[14,1231,1232],{},[34,1233,1236,1239,1242,1244,1251,1255,1258,1262,1277,1283,1288,1292,1295,1298,1302,1305,1309,1312,1317,1321,1325,1327,1331,1335,1337,1341,1345,1350,1353],{"viewBox":1234,"role":37,"ariaLabel":1235,"xmlns":39},"0 0 760 256","One parcel matching three valuation rows resolved three ways: keep the first match, duplicate the geometry once per match, or aggregate the matches before joining",[41,1237,1238],{},"Three answers to a one-to-many join",[45,1240,1241],{},"A single parcel matches three valuation rows from different years. The first-match option keeps one output row and silently discards two. The one-row-per-match option produces three output features sharing the same geometry. The aggregate-first option summarises the three rows into one before joining, producing a single output row carrying a total.",[49,1243],{"x":51,"y":51,"width":52,"height":156,"fill":54},[56,1245,1246],{},[59,1247,1249],{"id":1248,"viewBox":62,"refX":63,"refY":64,"markerWidth":65,"markerHeight":65,"orient":66},"joinOtmArrow",[68,1250],{"d":70,"fill":111},[73,1252,1254],{"x":75,"y":1253,"style":77,"fill":78,"textAnchor":79},"24","One parcel, three valuation rows — decide before you join",[49,1256],{"x":83,"y":84,"width":1257,"height":1018,"rx":87,"fill":88,"stroke":89,"style":90},"152",[73,1259,1261],{"x":1017,"y":94,"style":1260,"fill":89,"textAnchor":79},"text-anchor:middle;font-size:11.5px;font-weight:bold;font-family:sans-serif","parcel 1041",[104,1263,1265,1270,1273],{"style":1264},"font-size:10.5px;font-family:monospace",[73,1266,1269],{"x":1267,"y":1268,"fill":111},"32","96","2024  420 000",[73,1271,1272],{"x":1267,"y":1018,"fill":111},"2025  440 000",[73,1274,1276],{"x":1267,"y":1275,"fill":111},"144","2026  455 000",[49,1278],{"x":1279,"y":1280,"width":1281,"height":1282,"rx":63,"fill":88,"stroke":128,"style":1021},"216","42","248","58",[73,1284,1287],{"x":1285,"y":1286,"style":1260,"fill":128,"textAnchor":79},"340","64","METHOD 1 — first match",[73,1289,1291],{"x":1285,"y":1290,"style":100,"fill":111,"textAnchor":79},"84","1 row · two values silently lost",[49,1293],{"x":1279,"y":1294,"width":1281,"height":1282,"rx":63,"fill":88,"stroke":89,"style":1021},"112",[73,1296,1297],{"x":1285,"y":1029,"style":1260,"fill":89,"textAnchor":79},"METHOD 0 — one per match",[73,1299,1301],{"x":1285,"y":1300,"style":100,"fill":111,"textAnchor":79},"154","3 rows · geometry duplicated",[49,1303],{"x":1279,"y":1304,"width":1281,"height":1282,"rx":63,"fill":88,"stroke":1058,"style":90},"182",[73,1306,1308],{"x":1285,"y":1307,"style":1260,"fill":1058,"textAnchor":79},"204","aggregate, then join",[73,1310,1311],{"x":1285,"y":1033,"style":100,"fill":111,"textAnchor":79},"1 row · nothing lost",[49,1313],{"x":1314,"y":1280,"width":1315,"height":1282,"rx":63,"fill":88,"stroke":101,"style":1316},"512","232","stroke-width:1.5",[73,1318,1320],{"x":1319,"y":94,"style":100,"fill":111,"textAnchor":79},"628","use when the source is genuinely",[73,1322,1324],{"x":1319,"y":1323,"style":100,"fill":111,"textAnchor":79},"88","one-to-one and you are asserting it",[49,1326],{"x":1314,"y":1294,"width":1315,"height":1282,"rx":63,"fill":88,"stroke":101,"style":1316},[73,1328,1330],{"x":1319,"y":1329,"style":100,"fill":111,"textAnchor":79},"140","use for a per-event table where",[73,1332,1334],{"x":1319,"y":1333,"style":100,"fill":111,"textAnchor":79},"158","each row is its own record",[49,1336],{"x":1314,"y":1304,"width":1315,"height":1282,"rx":63,"fill":88,"stroke":101,"style":1316},[73,1338,1340],{"x":1319,"y":1339,"style":100,"fill":111,"textAnchor":79},"210","use when you want a total, a",[73,1342,1344],{"x":1319,"y":1343,"style":100,"fill":111,"textAnchor":79},"228","latest value or a mean per feature",[154,1346],{"x1":1347,"y1":99,"x2":1348,"y2":94,"stroke":111,"style":1349},"168","212","stroke-width:2;marker-end:url(#joinOtmArrow)",[154,1351],{"x1":1347,"y1":1352,"x2":1348,"y2":1329,"stroke":111,"style":1349},"106",[154,1354],{"x1":1347,"y1":109,"x2":1348,"y2":1355,"stroke":1058,"style":1349},"208",[14,1357,1358],{},"The third option — summarise first — is usually what a report actually wants, and it keeps the output one row per feature without discarding anything:",[227,1360,1362],{"className":229,"code":1361,"language":231,"meta":232,"style":232},"import processing\n\nsummary = processing.run(\"qgis:statisticsbycategories\", {\n    \"INPUT\": \"\u002Fdata\u002Fvaluations.csv\",\n    \"VALUES_FIELD_NAME\": \"assessed\",\n    \"CATEGORIES_FIELD_NAME\": [\"parcel_id\"],\n    \"OUTPUT\": \"TEMPORARY_OUTPUT\",\n})[\"OUTPUT\"]\n\njoined = processing.run(\"native:joinattributestable\", {\n    \"INPUT\": \"\u002Fdata\u002Fparcels.gpkg|layername=parcels\",\n    \"FIELD\": \"parcel_id\",\n    \"INPUT_2\": summary,\n    \"FIELD_2\": \"parcel_id\",\n    \"FIELDS_TO_COPY\": [\"count\", \"sum\", \"mean\", \"max\"],\n    \"METHOD\": 1,\n    \"DISCARD_NONMATCHING\": False,\n    \"PREFIX\": \"val_\",\n    \"OUTPUT\": \"\u002Fdata\u002Foutput\u002Fparcels_valued.gpkg\",\n})[\"OUTPUT\"]\n",[18,1363,1364,1370,1374,1388,1398,1409,1420,1430,1438,1442,1454,1464,1474,1481,1491,1517,1527,1537,1548,1559],{"__ignoreMap":232},[236,1365,1366,1368],{"class":154,"line":238},[236,1367,249],{"class":241},[236,1369,561],{"class":245},[236,1371,1372],{"class":154,"line":255},[236,1373,259],{"emptyLinePlaceholder":258},[236,1375,1376,1379,1381,1383,1386],{"class":154,"line":262},[236,1377,1378],{"class":245},"summary ",[236,1380,268],{"class":241},[236,1382,575],{"class":245},[236,1384,1385],{"class":274},"\"qgis:statisticsbycategories\"",[236,1387,581],{"class":245},[236,1389,1390,1392,1394,1396],{"class":154,"line":287},[236,1391,586],{"class":274},[236,1393,459],{"class":245},[236,1395,741],{"class":274},[236,1397,602],{"class":245},[236,1399,1400,1403,1405,1407],{"class":154,"line":306},[236,1401,1402],{"class":274},"    \"VALUES_FIELD_NAME\"",[236,1404,459],{"class":245},[236,1406,765],{"class":274},[236,1408,602],{"class":245},[236,1410,1411,1414,1416,1418],{"class":154,"line":311},[236,1412,1413],{"class":274},"    \"CATEGORIES_FIELD_NAME\"",[236,1415,762],{"class":245},[236,1417,504],{"class":274},[236,1419,773],{"class":245},[236,1421,1422,1424,1426,1428],{"class":154,"line":316},[236,1423,635],{"class":274},[236,1425,459],{"class":245},[236,1427,640],{"class":274},[236,1429,602],{"class":245},[236,1431,1432,1434,1436],{"class":154,"line":329},[236,1433,647],{"class":245},[236,1435,650],{"class":274},[236,1437,284],{"class":245},[236,1439,1440],{"class":154,"line":340},[236,1441,259],{"emptyLinePlaceholder":258},[236,1443,1444,1446,1448,1450,1452],{"class":154,"line":361},[236,1445,700],{"class":245},[236,1447,268],{"class":241},[236,1449,575],{"class":245},[236,1451,707],{"class":274},[236,1453,581],{"class":245},[236,1455,1456,1458,1460,1462],{"class":154,"line":395},[236,1457,586],{"class":274},[236,1459,459],{"class":245},[236,1461,718],{"class":274},[236,1463,602],{"class":245},[236,1465,1466,1468,1470,1472],{"class":154,"line":406},[236,1467,725],{"class":274},[236,1469,459],{"class":245},[236,1471,504],{"class":274},[236,1473,602],{"class":245},[236,1475,1476,1478],{"class":154,"line":434},[236,1477,736],{"class":274},[236,1479,1480],{"class":245},": summary,\n",[236,1482,1483,1485,1487,1489],{"class":154,"line":485},[236,1484,748],{"class":274},[236,1486,459],{"class":245},[236,1488,504],{"class":274},[236,1490,602],{"class":245},[236,1492,1493,1495,1497,1500,1502,1505,1507,1510,1512,1515],{"class":154,"line":490},[236,1494,759],{"class":274},[236,1496,762],{"class":245},[236,1498,1499],{"class":274},"\"count\"",[236,1501,533],{"class":245},[236,1503,1504],{"class":274},"\"sum\"",[236,1506,533],{"class":245},[236,1508,1509],{"class":274},"\"mean\"",[236,1511,533],{"class":245},[236,1513,1514],{"class":274},"\"max\"",[236,1516,773],{"class":245},[236,1518,1519,1521,1523,1525],{"class":154,"line":495},[236,1520,778],{"class":274},[236,1522,459],{"class":245},[236,1524,355],{"class":281},[236,1526,602],{"class":245},[236,1528,1529,1531,1533,1535],{"class":154,"line":510},[236,1530,793],{"class":274},[236,1532,459],{"class":245},[236,1534,798],{"class":281},[236,1536,602],{"class":245},[236,1538,1540,1542,1544,1546],{"class":154,"line":1539},18,[236,1541,805],{"class":274},[236,1543,459],{"class":245},[236,1545,810],{"class":274},[236,1547,602],{"class":245},[236,1549,1551,1553,1555,1557],{"class":154,"line":1550},19,[236,1552,635],{"class":274},[236,1554,459],{"class":245},[236,1556,821],{"class":274},[236,1558,602],{"class":245},[236,1560,1562,1564,1566],{"class":154,"line":1561},20,[236,1563,647],{"class":245},[236,1565,650],{"class":274},[236,1567,284],{"class":245},[14,1569,1570,1572,1573,1576,1577,1580,1581,1584,1585,450],{},[204,1571,524],{}," Aggregating first collapses the many side to exactly one row per key, so the subsequent join is genuinely one-to-one and ",[18,1574,1575],{},"METHOD: 1"," discards nothing. Copying ",[18,1578,1579],{},"count"," alongside the statistics is what makes the result auditable — a parcel with ",[18,1582,1583],{},"val_count"," of 3 tells the reader the figure summarises three valuations, which a bare total does not. The aggregation approach is covered in ",[26,1586,1588],{"href":1587},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fcount-features-by-attribute-pyqgis\u002F","Count and Summarise Features by Attribute in PyQGIS",[193,1590,1592],{"id":1591},"qgis-version-compatibility","QGIS version compatibility",[14,1594,1595,1596,1598],{},"The examples target ",[204,1597,206],{}," (Python 3.12).",[1600,1601,1602,1618],"table",{},[1603,1604,1605],"thead",{},[1606,1607,1608,1612,1615],"tr",{},[1609,1610,1611],"th",{},"QGIS version",[1609,1613,1614],{},"Python",[1609,1616,1617],{},"Notes",[1619,1620,1621,1636,1647],"tbody",{},[1606,1622,1623,1627,1630],{},[1624,1625,1626],"td",{},"3.28 LTR",[1624,1628,1629],{},"3.9",[1624,1631,1632,1633,1635],{},"Identical API. ",[18,1634,679],{}," parameters unchanged.",[1606,1637,1638,1641,1644],{},[1624,1639,1640],{},"3.34 LTR",[1624,1642,1643],{},"3.12",[1624,1645,1646],{},"Baseline for this page.",[1606,1648,1649,1652,1654],{},[1624,1650,1651],{},"3.40 \u002F 3.44",[1624,1653,1643],{},[1624,1655,1656,1657,1660],{},"Adds ",[18,1658,1659],{},"NON_MATCHING"," as a second output, making unmatched rows directly inspectable.",[14,1662,1663,1666],{},[18,1664,1665],{},"QgsVectorLayerJoinInfo"," and the join algorithm have been stable across 3.x.",[193,1668,1670],{"id":1669},"troubleshooting","Troubleshooting",[198,1672,1673,1685,1699,1716,1728,1734],{},[201,1674,1675,1678,1679,1681,1682,1684],{},[204,1676,1677],{},"Every joined value is NULL."," A key mismatch. Print ",[18,1680,528],{}," and a ",[18,1683,477],{}," sample of both key fields — the usual culprits are integer versus text, leading zeros, and trailing whitespace.",[201,1686,1687,525,1690,1692,1693,1695,1696,1698],{},[204,1688,1689],{},"The output has more features than the input.",[18,1691,859],{}," is ",[18,1694,51],{},", creating one feature per match. Use ",[18,1697,355],{}," if you want one row per input feature.",[201,1700,1701,525,1704,1692,1707,1709,1710,1712,1713,1715],{},[204,1702,1703],{},"Features vanished from the output.",[18,1705,1706],{},"DISCARD_NONMATCHING",[18,1708,1176],{},". Set it to ",[18,1711,798],{}," and audit the ",[18,1714,20],{},"s instead.",[201,1717,1718,1721,1722,1724,1725,1727],{},[204,1719,1720],{},"Joined column names are unwieldy."," Set ",[18,1723,876],{},", or ",[18,1726,1218],{}," on a virtual join; the default is derived from the source layer's name.",[201,1729,1730,1733],{},[204,1731,1732],{},"The join disappears when the project is reopened elsewhere."," It is a virtual join with a relative path that no longer resolves. Materialise it before sharing.",[201,1735,1736,1739,1740,1743,1744,1746],{},[204,1737,1738],{},"A CSV loads every column as text."," Add a ",[18,1741,1742],{},".csvt"," file next to it declaring the types, or cast the key with ",[18,1745,659],{}," before joining.",[193,1748,1750],{"id":1749},"conclusion","Conclusion",[14,1752,1753,1754,1756,1757,1759,1760,1762],{},"Check the key types before you join and the whole class of all-",[18,1755,20],{}," results disappears. Use ",[18,1758,679],{}," for anything that leaves your machine, because the result is self-contained; reserve ",[18,1761,980],{}," for interactive projects where the live link is the point. Either way, audit the match rate immediately — a join that silently matched nothing looks exactly like one that worked.",[193,1764,1766],{"id":1765},"frequently-asked-questions","Frequently Asked Questions",[14,1768,1769,1772,1773,1775],{},[204,1770,1771],{},"Why are all my joined values NULL?","\nThe keys do not compare equal, most often because one side is an integer and the other is text. Leading zeros and trailing whitespace from spreadsheet exports cause the same symptom. Print both fields' ",[18,1774,528],{}," and a repr of a few values.",[14,1777,1778,1781],{},[204,1779,1780],{},"What is the difference between a virtual join and native:joinattributestable?","\nA virtual join is a live, read-only view that depends on the source file remaining in place. The algorithm copies the values into a new layer, producing real, writable columns that survive the source being moved or deleted.",[14,1783,1784,1787,1788,1790,1791,1793,1794,525,1796,1798],{},[204,1785,1786],{},"How do I handle a one-to-many relationship?","\nSet ",[18,1789,859],{}," to ",[18,1792,51],{}," to produce one output feature per match, which duplicates the geometry. ",[18,1795,859],{},[18,1797,355],{}," keeps only the first match. If you need an aggregate instead, summarise the source table before joining.",[14,1800,1801,1804,1805,1808],{},[204,1802,1803],{},"Can I join on more than one field?","\nNot directly — both the algorithm and the virtual join take a single key on each side. Build a composite key first with ",[18,1806,1807],{},"native:fieldcalculator",", concatenating the parts with a separator that cannot occur in the values, then join on that.",[14,1810,1811,1814,1815,1818,1819,1821],{},[204,1812,1813],{},"Can I join a CSV without importing it first?","\nYes — pass the CSV path directly as ",[18,1816,1817],{},"INPUT_2",". Be aware that a CSV without a companion ",[18,1820,1742],{}," file loads every column as text, which is the most common reason the keys then fail to match.",[193,1823,1825],{"id":1824},"related","Related",[198,1827,1828,1833,1839,1843],{},[201,1829,1830,1832],{},[26,1831,29],{"href":28}," — the guide this recipe belongs to",[201,1834,1835],{},[26,1836,1838],{"href":1837},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fexport-attribute-table-to-csv-pyqgis\u002F","Export an Attribute Table to CSV with PyQGIS",[201,1840,1841],{},[26,1842,1588],{"href":1587},[201,1844,1845],{},[26,1846,1848],{"href":1847},"\u002Fspatial-data-processing-automation\u002Fvector-data-manipulation\u002Fautomating-shapefile-to-geojson-conversion\u002F","Automating Shapefile to GeoJSON Conversion",[1850,1851,1852],"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 .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}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":232,"searchDepth":255,"depth":255,"links":1854},[1855,1856,1857,1858,1859,1860,1861,1862,1863,1864],{"id":195,"depth":255,"text":196},{"id":221,"depth":255,"text":222},{"id":673,"depth":255,"text":674},{"id":974,"depth":255,"text":975},{"id":1222,"depth":255,"text":1223},{"id":1591,"depth":255,"text":1592},{"id":1669,"depth":255,"text":1670},{"id":1749,"depth":255,"text":1750},{"id":1765,"depth":255,"text":1766},{"id":1824,"depth":255,"text":1825},"Join a table onto a layer by a matching field in PyQGIS. Compare virtual joins with native:joinattributestable, and fix the key-type mismatches that produce all-NULL results.","md",{"slug":1868,"type":1869,"breadcrumb":1870,"datePublished":1871,"dateModified":1871},"join-attributes-by-field-value-pyqgis","article","Join by Field Value","2026-08-01","\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fjoin-attributes-by-field-value-pyqgis",{"title":5,"description":1865},"spatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fjoin-attributes-by-field-value-pyqgis\u002Findex","K0UhNc2SLwlauQgpXSGeUzBBVyMCQLJN9txMs6J7sHg",1785585990475]