[{"data":1,"prerenderedAt":1830},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Ffind-duplicate-geometries-and-features-pyqgis":3},{"id":4,"title":5,"body":6,"description":1819,"extension":1820,"meta":1821,"navigation":224,"path":1826,"seo":1827,"stem":1828,"__hash__":1829},"docs\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Ffind-duplicate-geometries-and-features-pyqgis\u002Findex.md","Find Duplicate Geometries and Features in PyQGIS",{"type":7,"value":8,"toc":1805},"minimark",[9,13,17,26,144,149,162,166,169,447,462,466,469,533,854,867,871,874,1080,1085,1089,1092,1186,1411,1420,1424,1427,1488,1501,1505,1508,1652,1657,1661,1680,1684,1718,1722,1725,1729,1738,1749,1760,1766,1770,1801],[10,11,5],"h1",{"id":12},"find-duplicate-geometries-and-features-in-pyqgis",[14,15,16],"p",{},"Duplicates creep into spatial data in predictable ways. An import script runs twice. Two survey teams capture the same hydrant from either side of the road. A merge of monthly extracts repeats every feature that did not change. A polygon is copied and pasted onto itself during editing. Each duplicate inflates counts, double-weights statistics and confuses joins — and the dangerous ones are not the exact copies, which are easy to spot, but the near-duplicates a few centimetres apart with slightly different attributes.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002F","Data Quality & Topology Validation",". It finds exact geometric duplicates, near-duplicate points within a distance, repeated identifiers, and features duplicated in attributes but not in location, then removes the extras with a rule that keeps the right copy.",[14,27,28],{},[29,30,35,39,43,50,59,69,75,81,85,90,95,99,102,105,108,113,117,120,123,126,131,135,138,141],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 280","img","Four kinds of duplicate feature: exact geometry, near duplicates within a tolerance, repeated keys and repeated attributes at different locations","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"Four kinds of duplicate",[40,41,42],"desc",{},"Exact geometry duplicates share identical coordinates, found by comparing WKB. Near duplicates are the same object captured twice within a small distance, found with a spatial index and a tolerance. Key duplicates share an identifier that should be unique, found by counting values. Attribute duplicates have the same attributes but different locations, which may be legitimate or may be a copy error.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","280","#f6f3ea",[51,52,58],"text",{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"380","28","text-anchor:middle;font-size:14px;font-family:sans-serif;font-weight:bold","#17211d","middle","Not all duplicates look the same",[44,60],{"x":61,"y":62,"width":63,"height":64,"rx":65,"fill":66,"stroke":67,"style":68},"24","52","168","196","8","#eef7f4","#0f766e","stroke-width:2",[51,70,74],{"x":71,"y":72,"style":73,"fill":67,"textAnchor":57},"108","111.78","text-anchor:middle;font-size:11.5px;font-family:sans-serif;font-weight:bold","exact geometry",[51,76,80],{"x":71,"y":77,"style":78,"fill":79,"textAnchor":57},"139.78","text-anchor:middle;font-size:10.5px;font-family:sans-serif","#2f3b35","identical",[51,82,84],{"x":71,"y":83,"style":78,"fill":79,"textAnchor":57},"167.78","coordinates",[51,86,89],{"x":71,"y":87,"style":78,"fill":88,"textAnchor":57},"195.78","#59645f","compare WKB",[44,91],{"x":92,"y":62,"width":63,"height":64,"rx":65,"fill":93,"stroke":94,"style":68},"206","#eff3ff","#2563eb",[51,96,98],{"x":97,"y":72,"style":73,"fill":94,"textAnchor":57},"290","near duplicate",[51,100,101],{"x":97,"y":77,"style":78,"fill":79,"textAnchor":57},"same object,",[51,103,104],{"x":97,"y":83,"style":78,"fill":79,"textAnchor":57},"a few cm apart",[51,106,107],{"x":97,"y":87,"style":78,"fill":88,"textAnchor":57},"index + distance",[44,109],{"x":110,"y":62,"width":63,"height":64,"rx":65,"fill":111,"stroke":112,"style":68},"388","#fdf2e2","#b45309",[51,114,116],{"x":115,"y":72,"style":73,"fill":112,"textAnchor":57},"472","key duplicate",[51,118,119],{"x":115,"y":77,"style":78,"fill":79,"textAnchor":57},"same ID twice",[51,121,122],{"x":115,"y":83,"style":78,"fill":79,"textAnchor":57},"any location",[51,124,125],{"x":115,"y":87,"style":78,"fill":88,"textAnchor":57},"count values",[44,127],{"x":128,"y":62,"width":129,"height":64,"rx":65,"fill":130,"stroke":88,"style":68},"570","166","#fffdf7",[51,132,134],{"x":133,"y":72,"style":73,"fill":56,"textAnchor":57},"653","attribute twin",[51,136,137],{"x":133,"y":77,"style":78,"fill":79,"textAnchor":57},"same values,",[51,139,140],{"x":133,"y":83,"style":78,"fill":79,"textAnchor":57},"other place",[51,142,143],{"x":133,"y":87,"style":78,"fill":88,"textAnchor":57},"maybe legitimate",[145,146,148],"h2",{"id":147},"prerequisites","Prerequisites",[150,151,152,156,159],"ul",{},[153,154,155],"li",{},"QGIS 3.34 LTR or newer, or the QGIS 4 series.",[153,157,158],{},"A layer in a projected CRS, so distance tolerances are in metres.",[153,160,161],{},"A definition of \"the same object\" for your data — a hydrant within 50 cm, a parcel with the same cadastral number — before you start deleting anything.",[145,163,165],{"id":164},"find-exact-geometric-duplicates","Find exact geometric duplicates",[14,167,168],{},"Exact duplicates have byte-identical geometry. Hashing the WKB representation groups them in a single pass, without any spatial comparison.",[170,171,176],"pre",{"className":172,"code":173,"language":174,"meta":175,"style":175},"language-python shiki shiki-themes github-dark","import hashlib\nfrom collections import defaultdict\nfrom qgis.core import QgsProject, QgsFeatureRequest\n\nhydrants = QgsProject.instance().mapLayersByName(\"hydrants\")[0]\n\ngroups = defaultdict(list)\nfor f in hydrants.getFeatures():\n    if not f.hasGeometry():\n        continue\n    key = hashlib.sha1(bytes(f.geometry().asWkb())).hexdigest()\n    groups[key].append(f.id())\n\ndupes = {k: ids for k, ids in groups.items() if len(ids) > 1}\nextra = sum(len(ids) - 1 for ids in dupes.values())\nprint(f\"{len(dupes)} geometries occur more than once; {extra} extra features\")\n","python","",[177,178,179,192,206,219,226,251,256,273,288,300,306,323,329,334,373,408],"code",{"__ignoreMap":175},[180,181,184,188],"span",{"class":182,"line":183},"line",1,[180,185,187],{"class":186},"snl16","import",[180,189,191],{"class":190},"s95oV"," hashlib\n",[180,193,195,198,201,203],{"class":182,"line":194},2,[180,196,197],{"class":186},"from",[180,199,200],{"class":190}," collections ",[180,202,187],{"class":186},[180,204,205],{"class":190}," defaultdict\n",[180,207,209,211,214,216],{"class":182,"line":208},3,[180,210,197],{"class":186},[180,212,213],{"class":190}," qgis.core ",[180,215,187],{"class":186},[180,217,218],{"class":190}," QgsProject, QgsFeatureRequest\n",[180,220,222],{"class":182,"line":221},4,[180,223,225],{"emptyLinePlaceholder":224},true,"\n",[180,227,229,232,235,238,242,245,248],{"class":182,"line":228},5,[180,230,231],{"class":190},"hydrants ",[180,233,234],{"class":186},"=",[180,236,237],{"class":190}," QgsProject.instance().mapLayersByName(",[180,239,241],{"class":240},"sU2Wk","\"hydrants\"",[180,243,244],{"class":190},")[",[180,246,46],{"class":247},"sDLfK",[180,249,250],{"class":190},"]\n",[180,252,254],{"class":182,"line":253},6,[180,255,225],{"emptyLinePlaceholder":224},[180,257,259,262,264,267,270],{"class":182,"line":258},7,[180,260,261],{"class":190},"groups ",[180,263,234],{"class":186},[180,265,266],{"class":190}," defaultdict(",[180,268,269],{"class":247},"list",[180,271,272],{"class":190},")\n",[180,274,276,279,282,285],{"class":182,"line":275},8,[180,277,278],{"class":186},"for",[180,280,281],{"class":190}," f ",[180,283,284],{"class":186},"in",[180,286,287],{"class":190}," hydrants.getFeatures():\n",[180,289,291,294,297],{"class":182,"line":290},9,[180,292,293],{"class":186},"    if",[180,295,296],{"class":186}," not",[180,298,299],{"class":190}," f.hasGeometry():\n",[180,301,303],{"class":182,"line":302},10,[180,304,305],{"class":186},"        continue\n",[180,307,309,312,314,317,320],{"class":182,"line":308},11,[180,310,311],{"class":190},"    key ",[180,313,234],{"class":186},[180,315,316],{"class":190}," hashlib.sha1(",[180,318,319],{"class":247},"bytes",[180,321,322],{"class":190},"(f.geometry().asWkb())).hexdigest()\n",[180,324,326],{"class":182,"line":325},12,[180,327,328],{"class":190},"    groups[key].append(f.id())\n",[180,330,332],{"class":182,"line":331},13,[180,333,225],{"emptyLinePlaceholder":224},[180,335,337,340,342,345,347,350,352,355,358,361,364,367,370],{"class":182,"line":336},14,[180,338,339],{"class":190},"dupes ",[180,341,234],{"class":186},[180,343,344],{"class":190}," {k: ids ",[180,346,278],{"class":186},[180,348,349],{"class":190}," k, ids ",[180,351,284],{"class":186},[180,353,354],{"class":190}," groups.items() ",[180,356,357],{"class":186},"if",[180,359,360],{"class":247}," len",[180,362,363],{"class":190},"(ids) ",[180,365,366],{"class":186},">",[180,368,369],{"class":247}," 1",[180,371,372],{"class":190},"}\n",[180,374,376,379,381,384,387,390,392,395,397,400,403,405],{"class":182,"line":375},15,[180,377,378],{"class":190},"extra ",[180,380,234],{"class":186},[180,382,383],{"class":247}," sum",[180,385,386],{"class":190},"(",[180,388,389],{"class":247},"len",[180,391,363],{"class":190},[180,393,394],{"class":186},"-",[180,396,369],{"class":247},[180,398,399],{"class":186}," for",[180,401,402],{"class":190}," ids ",[180,404,284],{"class":186},[180,406,407],{"class":190}," dupes.values())\n",[180,409,411,414,416,419,422,425,428,431,434,437,440,442,445],{"class":182,"line":410},16,[180,412,413],{"class":247},"print",[180,415,386],{"class":190},[180,417,418],{"class":186},"f",[180,420,421],{"class":240},"\"",[180,423,424],{"class":247},"{len",[180,426,427],{"class":190},"(dupes)",[180,429,430],{"class":247},"}",[180,432,433],{"class":240}," geometries occur more than once; ",[180,435,436],{"class":247},"{",[180,438,439],{"class":190},"extra",[180,441,430],{"class":247},[180,443,444],{"class":240}," extra features\"",[180,446,272],{"class":190},[14,448,449,453,454,457,458,461],{},[450,451,452],"strong",{},"Breakdown:"," Two geometries with the same WKB are identical in type, vertex order and every coordinate. Hashing keeps memory low on large layers — only a short digest per feature is stored. This misses polygons that are the same shape but start at a different vertex, and copies shifted by floating-point noise. For those, normalise first: ",[177,455,456],{},"g.normalize()"," (QGIS 3.20+) puts rings and vertices into a canonical order, after which equal shapes hash equally. Processing's ",[177,459,460],{},"native:deleteduplicategeometries"," does the exact-match case in one call and also reports how many it removed.",[145,463,465],{"id":464},"find-near-duplicate-points-within-a-tolerance","Find near-duplicate points within a tolerance",[14,467,468],{},"Points captured twice are rarely identical. A spatial index finds, for each point, any other point within the tolerance; grouping those pairs gives clusters of probable duplicates.",[14,470,471],{},[29,472,475,478,481,484,487,494,498,501,503,505,511,515,518,523,528],{"viewBox":473,"role":32,"ariaLabel":474,"xmlns":34},"0 0 760 250","Points within a half-metre tolerance chained into one duplicate group, while distant points remain single",[36,476,477],{},"Grouping points within a tolerance",[40,479,480],{},"Five points. Two of them lie 0.3 metres apart and form a group; a third lies 0.4 metres from the second and joins the same group even though it is 0.7 metres from the first. Two other points are several metres away and stay single. Groups are built with a union-find over all pairs closer than the 0.5 metre tolerance.",[44,482],{"x":46,"y":46,"width":47,"height":483,"fill":49},"250",[51,485,486],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"Pairs within 0.5 m chain into groups",[488,489],"circle",{"cx":490,"cy":491,"r":492,"fill":94,"stroke":56,"style":493},"120","130","9","stroke-width:1",[488,495],{"cx":496,"cy":497,"r":492,"fill":94,"stroke":56,"style":493},"160","122",[488,499],{"cx":64,"cy":500,"r":492,"fill":94,"stroke":56,"style":493},"140",[182,502],{"x1":490,"y1":491,"x2":496,"y2":497,"stroke":94,"style":68},[182,504],{"x1":496,"y1":497,"x2":64,"y2":500,"stroke":94,"style":68},[51,506,510],{"x":507,"y":508,"style":509,"fill":94,"textAnchor":57},"158","180","text-anchor:middle;font-size:11.0px;font-family:sans-serif","one group of 3",[488,512],{"cx":513,"cy":514,"r":492,"fill":88,"stroke":56,"style":493},"420","110",[488,516],{"cx":517,"cy":496,"r":492,"fill":88,"stroke":56,"style":493},"560",[51,519,522],{"x":520,"y":521,"style":509,"fill":88,"textAnchor":57},"490","200","singles: several metres apart",[44,524],{"x":500,"y":525,"width":526,"height":527,"rx":65,"fill":130,"stroke":88,"style":68},"212","480","30",[51,529,532],{"x":53,"y":530,"style":531,"fill":79,"textAnchor":57},"230.6","text-anchor:middle;font-size:10.0px;font-family:sans-serif","union-find over pairs closer than the tolerance",[170,534,536],{"className":172,"code":535,"language":174,"meta":175,"style":175},"from qgis.core import QgsSpatialIndex\n\nTOL = 0.5  # metres\nindex = QgsSpatialIndex(hydrants.getFeatures(),\n                        flags=QgsSpatialIndex.FlagStoreFeatureGeometries)\npoints = {f.id(): f.geometry() for f in hydrants.getFeatures()}\n\nparent = {fid: fid for fid in points}\ndef find(x):\n    while parent[x] != x:\n        parent[x] = parent[parent[x]]\n        x = parent[x]\n    return x\n\nfor fid, geom in points.items():\n    box = geom.boundingBox().buffered(TOL)\n    for other in index.intersects(box):\n        if other > fid and geom.distance(points[other]) \u003C= TOL:\n            parent[find(other)] = find(fid)\n\nclusters = defaultdict(list)\nfor fid in points:\n    clusters[find(fid)].append(fid)\nnear = [ids for ids in clusters.values() if len(ids) > 1]\nprint(len(near), \"groups of near-duplicate hydrants\")\n",[177,537,538,549,553,568,578,589,608,612,632,644,658,668,678,686,690,702,716,730,757,768,773,787,799,805,837],{"__ignoreMap":175},[180,539,540,542,544,546],{"class":182,"line":183},[180,541,197],{"class":186},[180,543,213],{"class":190},[180,545,187],{"class":186},[180,547,548],{"class":190}," QgsSpatialIndex\n",[180,550,551],{"class":182,"line":194},[180,552,225],{"emptyLinePlaceholder":224},[180,554,555,558,561,564],{"class":182,"line":208},[180,556,557],{"class":247},"TOL",[180,559,560],{"class":186}," =",[180,562,563],{"class":247}," 0.5",[180,565,567],{"class":566},"sjoCn","  # metres\n",[180,569,570,573,575],{"class":182,"line":221},[180,571,572],{"class":190},"index ",[180,574,234],{"class":186},[180,576,577],{"class":190}," QgsSpatialIndex(hydrants.getFeatures(),\n",[180,579,580,584,586],{"class":182,"line":228},[180,581,583],{"class":582},"s9osk","                        flags",[180,585,234],{"class":186},[180,587,588],{"class":190},"QgsSpatialIndex.FlagStoreFeatureGeometries)\n",[180,590,591,594,596,599,601,603,605],{"class":182,"line":253},[180,592,593],{"class":190},"points ",[180,595,234],{"class":186},[180,597,598],{"class":190}," {f.id(): f.geometry() ",[180,600,278],{"class":186},[180,602,281],{"class":190},[180,604,284],{"class":186},[180,606,607],{"class":190}," hydrants.getFeatures()}\n",[180,609,610],{"class":182,"line":258},[180,611,225],{"emptyLinePlaceholder":224},[180,613,614,617,619,622,624,627,629],{"class":182,"line":275},[180,615,616],{"class":190},"parent ",[180,618,234],{"class":186},[180,620,621],{"class":190}," {fid: fid ",[180,623,278],{"class":186},[180,625,626],{"class":190}," fid ",[180,628,284],{"class":186},[180,630,631],{"class":190}," points}\n",[180,633,634,637,641],{"class":182,"line":290},[180,635,636],{"class":186},"def",[180,638,640],{"class":639},"svObZ"," find",[180,642,643],{"class":190},"(x):\n",[180,645,646,649,652,655],{"class":182,"line":302},[180,647,648],{"class":186},"    while",[180,650,651],{"class":190}," parent[x] ",[180,653,654],{"class":186},"!=",[180,656,657],{"class":190}," x:\n",[180,659,660,663,665],{"class":182,"line":308},[180,661,662],{"class":190},"        parent[x] ",[180,664,234],{"class":186},[180,666,667],{"class":190}," parent[parent[x]]\n",[180,669,670,673,675],{"class":182,"line":325},[180,671,672],{"class":190},"        x ",[180,674,234],{"class":186},[180,676,677],{"class":190}," parent[x]\n",[180,679,680,683],{"class":182,"line":331},[180,681,682],{"class":186},"    return",[180,684,685],{"class":190}," x\n",[180,687,688],{"class":182,"line":336},[180,689,225],{"emptyLinePlaceholder":224},[180,691,692,694,697,699],{"class":182,"line":375},[180,693,278],{"class":186},[180,695,696],{"class":190}," fid, geom ",[180,698,284],{"class":186},[180,700,701],{"class":190}," points.items():\n",[180,703,704,707,709,712,714],{"class":182,"line":410},[180,705,706],{"class":190},"    box ",[180,708,234],{"class":186},[180,710,711],{"class":190}," geom.boundingBox().buffered(",[180,713,557],{"class":247},[180,715,272],{"class":190},[180,717,719,722,725,727],{"class":182,"line":718},17,[180,720,721],{"class":186},"    for",[180,723,724],{"class":190}," other ",[180,726,284],{"class":186},[180,728,729],{"class":190}," index.intersects(box):\n",[180,731,733,736,738,740,742,745,748,751,754],{"class":182,"line":732},18,[180,734,735],{"class":186},"        if",[180,737,724],{"class":190},[180,739,366],{"class":186},[180,741,626],{"class":190},[180,743,744],{"class":186},"and",[180,746,747],{"class":190}," geom.distance(points[other]) ",[180,749,750],{"class":186},"\u003C=",[180,752,753],{"class":247}," TOL",[180,755,756],{"class":190},":\n",[180,758,760,763,765],{"class":182,"line":759},19,[180,761,762],{"class":190},"            parent[find(other)] ",[180,764,234],{"class":186},[180,766,767],{"class":190}," find(fid)\n",[180,769,771],{"class":182,"line":770},20,[180,772,225],{"emptyLinePlaceholder":224},[180,774,776,779,781,783,785],{"class":182,"line":775},21,[180,777,778],{"class":190},"clusters ",[180,780,234],{"class":186},[180,782,266],{"class":190},[180,784,269],{"class":247},[180,786,272],{"class":190},[180,788,790,792,794,796],{"class":182,"line":789},22,[180,791,278],{"class":186},[180,793,626],{"class":190},[180,795,284],{"class":186},[180,797,798],{"class":190}," points:\n",[180,800,802],{"class":182,"line":801},23,[180,803,804],{"class":190},"    clusters[find(fid)].append(fid)\n",[180,806,808,811,813,816,818,820,822,825,827,829,831,833,835],{"class":182,"line":807},24,[180,809,810],{"class":190},"near ",[180,812,234],{"class":186},[180,814,815],{"class":190}," [ids ",[180,817,278],{"class":186},[180,819,402],{"class":190},[180,821,284],{"class":186},[180,823,824],{"class":190}," clusters.values() ",[180,826,357],{"class":186},[180,828,360],{"class":247},[180,830,363],{"class":190},[180,832,366],{"class":186},[180,834,369],{"class":247},[180,836,250],{"class":190},[180,838,840,842,844,846,849,852],{"class":182,"line":839},25,[180,841,413],{"class":247},[180,843,386],{"class":190},[180,845,389],{"class":247},[180,847,848],{"class":190},"(near), ",[180,850,851],{"class":240},"\"groups of near-duplicate hydrants\"",[180,853,272],{"class":190},[14,855,856,858,859,862,863,866],{},[450,857,452],{}," Buffering the bounding box by the tolerance turns the index's rectangle query into a \"within distance\" candidate search; the exact ",[177,860,861],{},"distance"," test then confirms each pair. Union-find merges pairs into groups, so three points in a chain become one group even if the ends are further apart than the tolerance — usually the right behaviour for repeated captures of one object. For large layers, ",[177,864,865],{},"QgsSpatialIndex.nearestNeighbor(point, k, maxDistance)"," is an alternative that returns only the closest candidates.",[145,868,870],{"id":869},"find-repeated-identifiers","Find repeated identifiers",[14,872,873],{},"A business key that should be unique — a hydrant number, a parcel ID, an address UPRN — is the most reliable duplicate test, because it does not depend on geometry at all.",[170,875,877],{"className":172,"code":876,"language":174,"meta":175,"style":175},"from collections import Counter\n\ncounts = Counter(f[\"hydrant_no\"] for f in hydrants.getFeatures(\n    QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)\n                       .setSubsetOfAttributes([\"hydrant_no\"], hydrants.fields())))\nrepeated = {k: n for k, n in counts.items() if n > 1 and k not in (None, \"\")}\nprint(len(repeated), \"hydrant numbers used more than once\")\nfor key, n in sorted(repeated.items(), key=lambda kv: -kv[1])[:10]:\n    print(f\"  {key}: {n} features\")\n",[177,878,879,890,894,919,924,934,990,1006,1047],{"__ignoreMap":175},[180,880,881,883,885,887],{"class":182,"line":183},[180,882,197],{"class":186},[180,884,200],{"class":190},[180,886,187],{"class":186},[180,888,889],{"class":190}," Counter\n",[180,891,892],{"class":182,"line":194},[180,893,225],{"emptyLinePlaceholder":224},[180,895,896,899,901,904,907,910,912,914,916],{"class":182,"line":208},[180,897,898],{"class":190},"counts ",[180,900,234],{"class":186},[180,902,903],{"class":190}," Counter(f[",[180,905,906],{"class":240},"\"hydrant_no\"",[180,908,909],{"class":190},"] ",[180,911,278],{"class":186},[180,913,281],{"class":190},[180,915,284],{"class":186},[180,917,918],{"class":190}," hydrants.getFeatures(\n",[180,920,921],{"class":182,"line":221},[180,922,923],{"class":190},"    QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)\n",[180,925,926,929,931],{"class":182,"line":228},[180,927,928],{"class":190},"                       .setSubsetOfAttributes([",[180,930,906],{"class":240},[180,932,933],{"class":190},"], hydrants.fields())))\n",[180,935,936,939,941,944,946,949,951,954,956,959,961,963,966,969,972,975,978,981,984,987],{"class":182,"line":253},[180,937,938],{"class":190},"repeated ",[180,940,234],{"class":186},[180,942,943],{"class":190}," {k: n ",[180,945,278],{"class":186},[180,947,948],{"class":190}," k, n ",[180,950,284],{"class":186},[180,952,953],{"class":190}," counts.items() ",[180,955,357],{"class":186},[180,957,958],{"class":190}," n ",[180,960,366],{"class":186},[180,962,369],{"class":247},[180,964,965],{"class":186}," and",[180,967,968],{"class":190}," k ",[180,970,971],{"class":186},"not",[180,973,974],{"class":186}," in",[180,976,977],{"class":190}," (",[180,979,980],{"class":247},"None",[180,982,983],{"class":190},", ",[180,985,986],{"class":240},"\"\"",[180,988,989],{"class":190},")}\n",[180,991,992,994,996,998,1001,1004],{"class":182,"line":258},[180,993,413],{"class":247},[180,995,386],{"class":190},[180,997,389],{"class":247},[180,999,1000],{"class":190},"(repeated), ",[180,1002,1003],{"class":240},"\"hydrant numbers used more than once\"",[180,1005,272],{"class":190},[180,1007,1008,1010,1013,1015,1018,1021,1024,1027,1030,1032,1035,1038,1041,1044],{"class":182,"line":275},[180,1009,278],{"class":186},[180,1011,1012],{"class":190}," key, n ",[180,1014,284],{"class":186},[180,1016,1017],{"class":247}," sorted",[180,1019,1020],{"class":190},"(repeated.items(), ",[180,1022,1023],{"class":582},"key",[180,1025,1026],{"class":186},"=lambda",[180,1028,1029],{"class":190}," kv: ",[180,1031,394],{"class":186},[180,1033,1034],{"class":190},"kv[",[180,1036,1037],{"class":247},"1",[180,1039,1040],{"class":190},"])[:",[180,1042,1043],{"class":247},"10",[180,1045,1046],{"class":190},"]:\n",[180,1048,1049,1052,1054,1056,1059,1061,1063,1065,1068,1070,1073,1075,1078],{"class":182,"line":290},[180,1050,1051],{"class":247},"    print",[180,1053,386],{"class":190},[180,1055,418],{"class":186},[180,1057,1058],{"class":240},"\"  ",[180,1060,436],{"class":247},[180,1062,1023],{"class":190},[180,1064,430],{"class":247},[180,1066,1067],{"class":240},": ",[180,1069,436],{"class":247},[180,1071,1072],{"class":190},"n",[180,1074,430],{"class":247},[180,1076,1077],{"class":240}," features\"",[180,1079,272],{"class":190},[14,1081,1082,1084],{},[450,1083,452],{}," Reading only the key column without geometry makes this fast even on large layers or remote databases. NULL and empty keys are excluded from the duplicate list but deserve their own count — a missing key is a separate defect. When a repeated key belongs to features in very different places, the problem is usually a data-entry error rather than a duplicate capture, and deleting one copy would lose a real feature. Combine the key check with the distance check: same key and close together is a duplicate; same key and far apart is a mislabelled feature.",[145,1086,1088],{"id":1087},"decide-which-copy-to-keep","Decide which copy to keep",[14,1090,1091],{},"Removing duplicates is easy; removing the right ones is the work. A keep-rule makes the choice consistent and explainable.",[14,1093,1094],{},[29,1095,1097,1100,1103,1105,1120,1123,1126,1131,1135,1139,1144,1147,1151,1154,1157,1161,1165,1169,1172,1175,1182],{"viewBox":473,"role":32,"ariaLabel":1096,"xmlns":34},"A ranking rule for each duplicate group: fewest NULLs, then latest edit date, then lowest id; the top feature is kept and the rest are archived for review",[36,1098,1099],{},"A keep rule for each group",[40,1101,1102],{},"Within each group of duplicates the features are ranked by rules in order: first the most complete record with the fewest NULL attributes, then the most recent edit date, then the lowest feature id as a stable tie-breaker. The top-ranked feature is kept and the others are written to a removed layer for review before deletion.",[44,1104],{"x":46,"y":46,"width":47,"height":483,"fill":49},[1106,1107,1108],"defs",{},[1109,1110,1116],"marker",{"id":1111,"viewBox":1112,"refX":65,"refY":1113,"markerWidth":1114,"markerHeight":1114,"orient":1115},"dupKeepArrow","0 0 10 10","5","7","auto-start-reverse",[1117,1118],"path",{"d":1119,"fill":79},"M0 0 L10 5 L0 10 z",[51,1121,1122],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"Rank, keep the first, archive the rest",[44,1124],{"x":61,"y":1125,"width":521,"height":490,"rx":65,"fill":66,"stroke":67,"style":68},"60",[51,1127,1130],{"x":1128,"y":1129,"style":73,"fill":67,"textAnchor":57},"124","97.78","1. completeness",[51,1132,1134],{"x":1128,"y":1133,"style":78,"fill":79,"textAnchor":57},"123.78","fewest NULL",[51,1136,1138],{"x":1128,"y":1137,"style":78,"fill":79,"textAnchor":57},"149.78","attributes",[182,1140],{"x1":1141,"y1":490,"x2":1142,"y2":490,"stroke":79,"style":1143},"224","266","stroke-width:1.8;marker-end:url(#dupKeepArrow)",[44,1145],{"x":1146,"y":1125,"width":521,"height":490,"rx":65,"fill":93,"stroke":94,"style":68},"270",[51,1148,1150],{"x":1149,"y":1129,"style":73,"fill":94,"textAnchor":57},"370","2. recency",[51,1152,1153],{"x":1149,"y":1133,"style":78,"fill":79,"textAnchor":57},"latest edit",[51,1155,1156],{"x":1149,"y":1137,"style":78,"fill":79,"textAnchor":57},"date",[182,1158],{"x1":1159,"y1":490,"x2":1160,"y2":490,"stroke":79,"style":1143},"470","512",[44,1162],{"x":1163,"y":1125,"width":1164,"height":490,"rx":65,"fill":111,"stroke":112,"style":68},"516","220",[51,1166,1168],{"x":1167,"y":1129,"style":73,"fill":112,"textAnchor":57},"626","3. tie-break",[51,1170,1171],{"x":1167,"y":1133,"style":78,"fill":79,"textAnchor":57},"lowest feature id",[51,1173,1174],{"x":1167,"y":1137,"style":78,"fill":88,"textAnchor":57},"stable across runs",[44,1176],{"x":1177,"y":521,"width":1178,"height":1179,"rx":65,"fill":1180,"stroke":1181,"style":68},"150","460","40","#e8efe6","#15803d",[51,1183,1185],{"x":53,"y":1184,"style":78,"fill":79,"textAnchor":57},"223.78","keep top · write others to a review layer",[170,1187,1189],{"className":172,"code":1188,"language":174,"meta":175,"style":175},"def null_count(f):\n    return sum(1 for v in f.attributes() if v is None or (hasattr(v, \"isNull\") and v.isNull()))\n\nto_delete = []\nfor ids in near:\n    feats = list(hydrants.getFeatures(QgsFeatureRequest().setFilterFids(ids)))\n    feats.sort(key=lambda f: (null_count(f), -f[\"edited_on\"].toMSecsSinceEpoch()\n                              if f[\"edited_on\"] else 0, f.id()))\n    keep, *rest = feats\n    to_delete.extend(r.id() for r in rest)\n\nremoved = hydrants.materialize(QgsFeatureRequest().setFilterFids(to_delete))\nremoved.setName(\"hydrants removed as duplicates\")\nQgsProject.instance().addMapLayer(removed)\nprint(len(to_delete), \"features marked for removal\")\n",[177,1190,1191,1201,1253,1257,1267,1278,1291,1314,1335,1351,1366,1370,1380,1390,1395],{"__ignoreMap":175},[180,1192,1193,1195,1198],{"class":182,"line":183},[180,1194,636],{"class":186},[180,1196,1197],{"class":639}," null_count",[180,1199,1200],{"class":190},"(f):\n",[180,1202,1203,1205,1207,1209,1211,1213,1216,1218,1221,1223,1225,1228,1231,1234,1236,1239,1242,1245,1248,1250],{"class":182,"line":194},[180,1204,682],{"class":186},[180,1206,383],{"class":247},[180,1208,386],{"class":190},[180,1210,1037],{"class":247},[180,1212,399],{"class":186},[180,1214,1215],{"class":190}," v ",[180,1217,284],{"class":186},[180,1219,1220],{"class":190}," f.attributes() ",[180,1222,357],{"class":186},[180,1224,1215],{"class":190},[180,1226,1227],{"class":186},"is",[180,1229,1230],{"class":247}," None",[180,1232,1233],{"class":186}," or",[180,1235,977],{"class":190},[180,1237,1238],{"class":247},"hasattr",[180,1240,1241],{"class":190},"(v, ",[180,1243,1244],{"class":240},"\"isNull\"",[180,1246,1247],{"class":190},") ",[180,1249,744],{"class":186},[180,1251,1252],{"class":190}," v.isNull()))\n",[180,1254,1255],{"class":182,"line":208},[180,1256,225],{"emptyLinePlaceholder":224},[180,1258,1259,1262,1264],{"class":182,"line":221},[180,1260,1261],{"class":190},"to_delete ",[180,1263,234],{"class":186},[180,1265,1266],{"class":190}," []\n",[180,1268,1269,1271,1273,1275],{"class":182,"line":228},[180,1270,278],{"class":186},[180,1272,402],{"class":190},[180,1274,284],{"class":186},[180,1276,1277],{"class":190}," near:\n",[180,1279,1280,1283,1285,1288],{"class":182,"line":253},[180,1281,1282],{"class":190},"    feats ",[180,1284,234],{"class":186},[180,1286,1287],{"class":247}," list",[180,1289,1290],{"class":190},"(hydrants.getFeatures(QgsFeatureRequest().setFilterFids(ids)))\n",[180,1292,1293,1296,1298,1300,1303,1305,1308,1311],{"class":182,"line":258},[180,1294,1295],{"class":190},"    feats.sort(",[180,1297,1023],{"class":582},[180,1299,1026],{"class":186},[180,1301,1302],{"class":190}," f: (null_count(f), ",[180,1304,394],{"class":186},[180,1306,1307],{"class":190},"f[",[180,1309,1310],{"class":240},"\"edited_on\"",[180,1312,1313],{"class":190},"].toMSecsSinceEpoch()\n",[180,1315,1316,1319,1322,1324,1326,1329,1332],{"class":182,"line":275},[180,1317,1318],{"class":186},"                              if",[180,1320,1321],{"class":190}," f[",[180,1323,1310],{"class":240},[180,1325,909],{"class":190},[180,1327,1328],{"class":186},"else",[180,1330,1331],{"class":247}," 0",[180,1333,1334],{"class":190},", f.id()))\n",[180,1336,1337,1340,1343,1346,1348],{"class":182,"line":290},[180,1338,1339],{"class":190},"    keep, ",[180,1341,1342],{"class":186},"*",[180,1344,1345],{"class":190},"rest ",[180,1347,234],{"class":186},[180,1349,1350],{"class":190}," feats\n",[180,1352,1353,1356,1358,1361,1363],{"class":182,"line":302},[180,1354,1355],{"class":190},"    to_delete.extend(r.id() ",[180,1357,278],{"class":186},[180,1359,1360],{"class":190}," r ",[180,1362,284],{"class":186},[180,1364,1365],{"class":190}," rest)\n",[180,1367,1368],{"class":182,"line":308},[180,1369,225],{"emptyLinePlaceholder":224},[180,1371,1372,1375,1377],{"class":182,"line":325},[180,1373,1374],{"class":190},"removed ",[180,1376,234],{"class":186},[180,1378,1379],{"class":190}," hydrants.materialize(QgsFeatureRequest().setFilterFids(to_delete))\n",[180,1381,1382,1385,1388],{"class":182,"line":331},[180,1383,1384],{"class":190},"removed.setName(",[180,1386,1387],{"class":240},"\"hydrants removed as duplicates\"",[180,1389,272],{"class":190},[180,1391,1392],{"class":182,"line":336},[180,1393,1394],{"class":190},"QgsProject.instance().addMapLayer(removed)\n",[180,1396,1397,1399,1401,1403,1406,1409],{"class":182,"line":375},[180,1398,413],{"class":247},[180,1400,386],{"class":190},[180,1402,389],{"class":247},[180,1404,1405],{"class":190},"(to_delete), ",[180,1407,1408],{"class":240},"\"features marked for removal\"",[180,1410,272],{"class":190},[14,1412,1413,1415,1416,1419],{},[450,1414,452],{}," Sorting by a tuple applies the rules in order: fewest NULLs first, then the most recent edit (negated so later dates sort first), then the lowest id. The id tie-breaker makes the result deterministic — run it twice and the same features survive. Materialising the losers into a separate layer before deletion means nothing is lost if the rule turns out to be wrong for some group; review that layer, then delete. ",[177,1417,1418],{},"edited_on"," here is a datetime field; adapt the rule to whatever your data records about quality and age.",[145,1421,1423],{"id":1422},"delete-and-verify","Delete and verify",[14,1425,1426],{},"Deletion goes through the edit buffer so it can be undone, and a second run of the check confirms the result.",[170,1428,1430],{"className":172,"code":1429,"language":174,"meta":175,"style":175},"from qgis.core import edit\n\nbefore = hydrants.featureCount()\nwith edit(hydrants):\n    hydrants.deleteFeatures(to_delete)\nprint(before, \"→\", hydrants.featureCount(), \"features\")\n",[177,1431,1432,1443,1447,1457,1465,1470],{"__ignoreMap":175},[180,1433,1434,1436,1438,1440],{"class":182,"line":183},[180,1435,197],{"class":186},[180,1437,213],{"class":190},[180,1439,187],{"class":186},[180,1441,1442],{"class":190}," edit\n",[180,1444,1445],{"class":182,"line":194},[180,1446,225],{"emptyLinePlaceholder":224},[180,1448,1449,1452,1454],{"class":182,"line":208},[180,1450,1451],{"class":190},"before ",[180,1453,234],{"class":186},[180,1455,1456],{"class":190}," hydrants.featureCount()\n",[180,1458,1459,1462],{"class":182,"line":221},[180,1460,1461],{"class":186},"with",[180,1463,1464],{"class":190}," edit(hydrants):\n",[180,1466,1467],{"class":182,"line":228},[180,1468,1469],{"class":190},"    hydrants.deleteFeatures(to_delete)\n",[180,1471,1472,1474,1477,1480,1483,1486],{"class":182,"line":253},[180,1473,413],{"class":247},[180,1475,1476],{"class":190},"(before, ",[180,1478,1479],{"class":240},"\"→\"",[180,1481,1482],{"class":190},", hydrants.featureCount(), ",[180,1484,1485],{"class":240},"\"features\"",[180,1487,272],{"class":190},[14,1489,1490,1492,1493,1496,1497,1500],{},[450,1491,452],{}," Using ",[177,1494,1495],{},"edit()"," means a failure rolls back cleanly, and an interactive user can still press undo if they ran the script from the console and immediately see a problem. Rerunning the near-duplicate search should now return no groups at the same tolerance; if it returns new ones, the union-find merged chains that span several captures and the tolerance may be too generous. For tables where duplicates differ only by attribute history — the monthly extract case — ",[177,1498,1499],{},"native:removeduplicatesbyattribute"," (3.20+) removes them by chosen fields in one Processing call.",[145,1502,1504],{"id":1503},"attribute-twins-at-different-places","Attribute twins at different places",[14,1506,1507],{},"The last case is features with identical attributes at different locations: two \"Oak, girth 120 cm\" trees in different streets. These are often legitimate, but a burst of them in one batch can reveal a copy-paste error during digitizing.",[170,1509,1511],{"className":172,"code":1510,"language":174,"meta":175,"style":175},"trees = QgsProject.instance().mapLayersByName(\"street_trees\")[0]\nfields_to_compare = [\"species\", \"girth_cm\", \"planted\"]\nsig = defaultdict(list)\nfor f in trees.getFeatures(QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)):\n    sig[tuple(str(f[n]) for n in fields_to_compare)].append(f.id())\ntwins = [ids for ids in sig.values() if len(ids) > 3]\nprint(len(twins), \"attribute signatures shared by more than three features\")\n",[177,1512,1513,1531,1556,1569,1580,1605,1636],{"__ignoreMap":175},[180,1514,1515,1518,1520,1522,1525,1527,1529],{"class":182,"line":183},[180,1516,1517],{"class":190},"trees ",[180,1519,234],{"class":186},[180,1521,237],{"class":190},[180,1523,1524],{"class":240},"\"street_trees\"",[180,1526,244],{"class":190},[180,1528,46],{"class":247},[180,1530,250],{"class":190},[180,1532,1533,1536,1538,1541,1544,1546,1549,1551,1554],{"class":182,"line":194},[180,1534,1535],{"class":190},"fields_to_compare ",[180,1537,234],{"class":186},[180,1539,1540],{"class":190}," [",[180,1542,1543],{"class":240},"\"species\"",[180,1545,983],{"class":190},[180,1547,1548],{"class":240},"\"girth_cm\"",[180,1550,983],{"class":190},[180,1552,1553],{"class":240},"\"planted\"",[180,1555,250],{"class":190},[180,1557,1558,1561,1563,1565,1567],{"class":182,"line":208},[180,1559,1560],{"class":190},"sig ",[180,1562,234],{"class":186},[180,1564,266],{"class":190},[180,1566,269],{"class":247},[180,1568,272],{"class":190},[180,1570,1571,1573,1575,1577],{"class":182,"line":221},[180,1572,278],{"class":186},[180,1574,281],{"class":190},[180,1576,284],{"class":186},[180,1578,1579],{"class":190}," trees.getFeatures(QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)):\n",[180,1581,1582,1585,1588,1590,1593,1596,1598,1600,1602],{"class":182,"line":228},[180,1583,1584],{"class":190},"    sig[",[180,1586,1587],{"class":247},"tuple",[180,1589,386],{"class":190},[180,1591,1592],{"class":247},"str",[180,1594,1595],{"class":190},"(f[n]) ",[180,1597,278],{"class":186},[180,1599,958],{"class":190},[180,1601,284],{"class":186},[180,1603,1604],{"class":190}," fields_to_compare)].append(f.id())\n",[180,1606,1607,1610,1612,1614,1616,1618,1620,1623,1625,1627,1629,1631,1634],{"class":182,"line":253},[180,1608,1609],{"class":190},"twins ",[180,1611,234],{"class":186},[180,1613,815],{"class":190},[180,1615,278],{"class":186},[180,1617,402],{"class":190},[180,1619,284],{"class":186},[180,1621,1622],{"class":190}," sig.values() ",[180,1624,357],{"class":186},[180,1626,360],{"class":247},[180,1628,363],{"class":190},[180,1630,366],{"class":186},[180,1632,1633],{"class":247}," 3",[180,1635,250],{"class":190},[180,1637,1638,1640,1642,1644,1647,1650],{"class":182,"line":258},[180,1639,413],{"class":247},[180,1641,386],{"class":190},[180,1643,389],{"class":247},[180,1645,1646],{"class":190},"(twins), ",[180,1648,1649],{"class":240},"\"attribute signatures shared by more than three features\"",[180,1651,272],{"class":190},[14,1653,1654,1656],{},[450,1655,452],{}," Grouping by an attribute signature finds repeated records regardless of where they are. A threshold above two filters out the coincidences that any large dataset contains. These groups are for review, not deletion: look at their creation dates and editors to see whether they came from one session.",[145,1658,1660],{"id":1659},"qgis-version-compatibility","QGIS version compatibility",[14,1662,1663,1666,1667,1669,1670,1672,1673,1675,1676,1679],{},[177,1664,1665],{},"QgsGeometry.normalize()"," needs QGIS 3.20 or newer; ",[177,1668,460],{}," has existed since 3.0 and ",[177,1671,1499],{}," since 3.20. The rest of the code runs on any QGIS 3.x and QGIS 4 release. On QGIS 4, NULL datetime values arrive as ",[177,1674,980],{},", which the ",[177,1677,1678],{},"if f[\"edited_on\"]"," test already handles.",[145,1681,1683],{"id":1682},"troubleshooting","Troubleshooting",[150,1685,1686,1696,1702,1708],{},[153,1687,1688,1691,1692,1695],{},[450,1689,1690],{},"Exact duplicates are not detected."," Coordinates differ in the last decimal place; snap to a grid with ",[177,1693,1694],{},"native:snappointstogrid"," before hashing, or use the tolerance approach.",[153,1697,1698,1701],{},[450,1699,1700],{},"One huge group of near duplicates."," The tolerance is larger than the spacing between real objects; reduce it.",[153,1703,1704,1707],{},[450,1705,1706],{},"The wrong copy was kept."," The keep rule did not reflect your data; adjust the sort key and restore from the review layer.",[153,1709,1710,1713,1714,1717],{},[450,1711,1712],{},"Deleting is slow on PostGIS."," Pass ids in one ",[177,1715,1716],{},"deleteFeatures"," call, not in a loop.",[145,1719,1721],{"id":1720},"conclusion","Conclusion",[14,1723,1724],{},"Hash WKB for exact duplicates, use a spatial index with a tolerance and union-find for near duplicates, count business keys without reading geometry, choose survivors with an explicit ranking rule, archive the losers before deleting, and treat attribute twins as a review list rather than a delete list.",[145,1726,1728],{"id":1727},"frequently-asked-questions","Frequently Asked Questions",[14,1730,1731,1734,1735,1737],{},[450,1732,1733],{},"Is there a single Processing algorithm for all of this?","\nNo. ",[177,1736,460],{}," handles exact matches; near duplicates and keep rules need a script.",[14,1739,1740,1743,1744,1748],{},[450,1741,1742],{},"How do I find duplicate polygons that overlap almost completely?","\nCompare intersection area to each polygon's area; a ratio above 0.95 on both sides is a near duplicate. The overlap search in ",[21,1745,1747],{"href":1746},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Ffind-gaps-and-overlaps-between-polygons-pyqgis\u002F","finding gaps and overlaps"," gives you the intersections.",[14,1750,1751,1754,1755,1759],{},[450,1752,1753],{},"Should I prevent duplicates instead of cleaning them?","\nYes, where possible: a unique constraint on the key field rejects duplicates at entry, as covered in ",[21,1756,1758],{"href":1757},"\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fset-default-values-and-field-constraints-pyqgis\u002F","default values and constraints",".",[14,1761,1762,1765],{},[450,1763,1764],{},"Does deleting duplicates break relations?","\nIt can. Check child tables for references to deleted ids and repoint them to the kept feature first.",[145,1767,1769],{"id":1768},"related","Related",[150,1771,1772,1777,1783,1789,1795],{},[153,1773,1774,1776],{},[21,1775,24],{"href":23}," — the guide this recipe belongs to",[153,1778,1779],{},[21,1780,1782],{"href":1781},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fvalidate-attribute-values-against-rules-pyqgis\u002F","Validate Attribute Values Against Rules in PyQGIS",[153,1784,1785],{},[21,1786,1788],{"href":1787},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fbuild-data-quality-report-pyqgis\u002F","Build a Data Quality Report in PyQGIS",[153,1790,1791],{},[21,1792,1794],{"href":1793},"\u002Fspatial-data-processing-automation\u002Fgeometry-operations-and-predicates\u002Fbuild-spatial-index-pyqgis\u002F","Build a Spatial Index in PyQGIS",[153,1796,1797],{},[21,1798,1800],{"href":1799},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fcount-features-by-attribute-pyqgis\u002F","Count Features by Attribute in PyQGIS",[1802,1803,1804],"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 .sjoCn, html code.shiki .sjoCn{--shiki-default:#9AA79F}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":175,"searchDepth":194,"depth":194,"links":1806},[1807,1808,1809,1810,1811,1812,1813,1814,1815,1816,1817,1818],{"id":147,"depth":194,"text":148},{"id":164,"depth":194,"text":165},{"id":464,"depth":194,"text":465},{"id":869,"depth":194,"text":870},{"id":1087,"depth":194,"text":1088},{"id":1422,"depth":194,"text":1423},{"id":1503,"depth":194,"text":1504},{"id":1659,"depth":194,"text":1660},{"id":1682,"depth":194,"text":1683},{"id":1720,"depth":194,"text":1721},{"id":1727,"depth":194,"text":1728},{"id":1768,"depth":194,"text":1769},"Detect exact and near-duplicate features in a vector layer — identical geometries, the same point captured twice within a tolerance, repeated business keys and attribute-only duplicates — and decide which copy to keep with a rule you can explain.","md",{"slug":1822,"type":1823,"breadcrumb":1824,"datePublished":1825,"dateModified":1825},"find-duplicate-geometries-and-features-pyqgis","article","Find Duplicate Geometries and Features","2026-10-02","\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Ffind-duplicate-geometries-and-features-pyqgis",{"title":5,"description":1819},"spatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Ffind-duplicate-geometries-and-features-pyqgis\u002Findex","_LKQkaPqkYPxFWwViy6im8evPiGMncnBOesUKr92hKw",1790966261433]