[{"data":1,"prerenderedAt":2103},["ShallowReactive",2],{"doc:\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fvalidate-attribute-values-against-rules-pyqgis":3},{"id":4,"title":5,"body":6,"description":2092,"extension":2093,"meta":2094,"navigation":563,"path":2099,"seo":2100,"stem":2101,"__hash__":2102},"docs\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fvalidate-attribute-values-against-rules-pyqgis\u002Findex.md","Validate Attribute Values Against Rules in PyQGIS",{"type":7,"value":8,"toc":2078},"minimark",[9,13,17,26,170,175,193,197,205,505,528,532,535,901,922,926,933,1031,1260,1270,1274,1277,1442,1456,1460,1463,1553,1757,1762,1766,1769,1916,1930,1934,1948,1952,1992,1996,1999,2003,2009,2022,2028,2041,2045,2074],[10,11,5],"h1",{"id":12},"validate-attribute-values-against-rules-in-pyqgis",[14,15,16],"p",{},"Geometry gets most of the attention in spatial quality control, but attributes are where most errors live. A pipe with a diameter of 0, a tree planted in 2087, a road classified as \"Primay\", an inspection date before the installation date, a zoning code that exists in no lookup table — none of these show up on a map, and all of them break the analysis that relies on them. Checking attributes is straightforward once the rules are written down; the trick is to write them in a form that is easy to read, easy to extend, and produces a list of violations someone can act on.",[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 expresses rules as QGIS expressions, evaluates them efficiently against every feature, checks references against lookup tables, and writes the violations to a table with the rule, the feature and the offending value.",[14,27,28],{},[29,30,35,39,43,50,67,76,85,91,96,100,104,110,117,125,131,136,140,144,150,155,159,162,166],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 280","img","A rule list of QGIS expressions evaluated against every feature, producing a violations table with rule, feature key, value and severity","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"Rules as data, violations as a table",[40,41,42],"desc",{},"A list of rules, each with an id, a description, a severity and a QGIS expression that is true when a feature passes. The validator evaluates every rule against every feature using one expression context per layer. Each failure becomes a row in a violations table holding the rule id, the feature key, the field value and the severity, which can be filtered, counted and joined back to the layer.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","280","#f6f3ea",[51,52,53],"defs",{},[54,55,62],"marker",{"id":56,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"attrFlowArrow","0 0 10 10","8","5","7","auto-start-reverse",[63,64],"path",{"d":65,"fill":66},"M0 0 L10 5 L0 10 z","#2f3b35",[68,69,75],"text",{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"380","28","text-anchor:middle;font-size:14px;font-family:sans-serif;font-weight:bold","#17211d","middle","Write the rules once, read the violations anywhere",[44,77],{"x":78,"y":79,"width":80,"height":81,"rx":58,"fill":82,"stroke":83,"style":84},"24","52","250","196","#eff3ff","#2563eb","stroke-width:2",[68,86,90],{"x":87,"y":88,"style":89,"fill":83,"textAnchor":74},"149","93.6","text-anchor:middle;font-size:11.0px;font-family:sans-serif;font-weight:bold","rules",[68,92,95],{"x":87,"y":93,"style":94,"fill":66,"textAnchor":74},"123.6","text-anchor:middle;font-size:9.5px;font-family:monospace","R01  diameter_mm > 0",[68,97,99],{"x":87,"y":98,"style":94,"fill":66,"textAnchor":74},"153.6","R02  status IN (…)",[68,101,103],{"x":87,"y":102,"style":94,"fill":66,"textAnchor":74},"183.6","R03  installed \u003C= now()",[68,105,109],{"x":87,"y":106,"style":107,"fill":108,"textAnchor":74},"213.6","text-anchor:middle;font-size:10.0px;font-family:sans-serif","#59645f","id · text · severity · expr",[111,112],"line",{"x1":113,"y1":114,"x2":115,"y2":114,"stroke":66,"style":116},"274","150","318","stroke-width:1.8;marker-end:url(#attrFlowArrow)",[44,118],{"x":119,"y":120,"width":121,"height":122,"rx":58,"fill":123,"stroke":124,"style":84},"326","92","160","116","#eef7f4","#0f766e",[68,126,130],{"x":127,"y":128,"style":129,"fill":124,"textAnchor":74},"406","129.78","text-anchor:middle;font-size:11.5px;font-family:sans-serif;font-weight:bold","validator",[68,132,135],{"x":127,"y":133,"style":134,"fill":108,"textAnchor":74},"153.78","text-anchor:middle;font-size:10.5px;font-family:sans-serif","one context",[68,137,139],{"x":127,"y":138,"style":134,"fill":108,"textAnchor":74},"177.78","per layer",[111,141],{"x1":142,"y1":114,"x2":143,"y2":114,"stroke":66,"style":116},"486","530",[44,145],{"x":146,"y":79,"width":147,"height":81,"rx":58,"fill":148,"stroke":149,"style":84},"538","198","#fdf2e2","#b45309",[68,151,154],{"x":152,"y":153,"style":129,"fill":149,"textAnchor":74},"637","93.78","violations",[68,156,158],{"x":152,"y":157,"style":134,"fill":66,"textAnchor":74},"123.78","rule_id",[68,160,161],{"x":152,"y":133,"style":134,"fill":66,"textAnchor":74},"feature key",[68,163,165],{"x":152,"y":164,"style":134,"fill":66,"textAnchor":74},"183.78","value · severity",[68,167,169],{"x":152,"y":168,"style":134,"fill":108,"textAnchor":74},"213.78","filter, count, join",[171,172,174],"h2",{"id":173},"prerequisites","Prerequisites",[176,177,178,182,185],"ul",{},[179,180,181],"li",{},"QGIS 3.34 LTR or newer, or the QGIS 4 series.",[179,183,184],{},"The rules your data should satisfy, ideally agreed with the people who maintain it. Rules nobody agreed to produce reports nobody acts on.",[179,186,187,188,192],{},"Familiarity with ",[21,189,191],{"href":190},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-expressions\u002Fevaluate-qgis-expression-in-pyqgis\u002F","evaluating QGIS expressions in PyQGIS",".",[171,194,196],{"id":195},"write-rules-as-expressions","Write rules as expressions",[14,198,199,200,204],{},"Expressing each rule as a QGIS expression that is true when a feature ",[201,202,203],"em",{},"passes"," has three advantages: the same syntax as the field calculator and filters, so non-programmers can read and propose rules; NULL handling built in; and access to functions for dates, strings, regular expressions and geometry.",[206,207,212],"pre",{"className":208,"code":209,"language":210,"meta":211,"style":211},"language-python shiki shiki-themes github-dark","RULES = [\n    (\"R01\", \"diameter must be positive\", \"error\",\n     '\"diameter_mm\" > 0'),\n    (\"R02\", \"material from the allowed list\", \"error\",\n     '\"material\" IN (\\'PE\\', \\'PVC\\', \\'DI\\', \\'CI\\', \\'ST\\')'),\n    (\"R03\", \"installation year not in the future\", \"error\",\n     '\"install_year\" \u003C= year(now())'),\n    (\"R04\", \"installation year plausible\", \"warning\",\n     '\"install_year\" >= 1850'),\n    (\"R05\", \"asset id format WP-000000\", \"error\",\n     'regexp_match(\"asset_id\", \\'^WP-[0-9]{6}$\\')'),\n    (\"R06\", \"inspection after installation\", \"error\",\n     '\"last_inspected\" IS NULL OR year(\"last_inspected\") >= \"install_year\"'),\n    (\"R07\", \"mains need a pressure zone\", \"warning\",\n     '\"pipe_type\" \u003C> \\'main\\' OR \"pressure_zone\" IS NOT NULL'),\n]\n","python","",[213,214,215,231,255,264,283,338,357,365,385,393,412,435,454,462,481,499],"code",{"__ignoreMap":211},[216,217,219,223,227],"span",{"class":111,"line":218},1,[216,220,222],{"class":221},"sDLfK","RULES",[216,224,226],{"class":225},"snl16"," =",[216,228,230],{"class":229},"s95oV"," [\n",[216,232,234,237,241,244,247,249,252],{"class":111,"line":233},2,[216,235,236],{"class":229},"    (",[216,238,240],{"class":239},"sU2Wk","\"R01\"",[216,242,243],{"class":229},", ",[216,245,246],{"class":239},"\"diameter must be positive\"",[216,248,243],{"class":229},[216,250,251],{"class":239},"\"error\"",[216,253,254],{"class":229},",\n",[216,256,258,261],{"class":111,"line":257},3,[216,259,260],{"class":239},"     '\"diameter_mm\" > 0'",[216,262,263],{"class":229},"),\n",[216,265,267,269,272,274,277,279,281],{"class":111,"line":266},4,[216,268,236],{"class":229},[216,270,271],{"class":239},"\"R02\"",[216,273,243],{"class":229},[216,275,276],{"class":239},"\"material from the allowed list\"",[216,278,243],{"class":229},[216,280,251],{"class":239},[216,282,254],{"class":229},[216,284,286,289,292,295,297,299,301,304,306,308,310,313,315,317,319,322,324,326,328,331,333,336],{"class":111,"line":285},5,[216,287,288],{"class":239},"     '\"material\" IN (",[216,290,291],{"class":221},"\\'",[216,293,294],{"class":239},"PE",[216,296,291],{"class":221},[216,298,243],{"class":239},[216,300,291],{"class":221},[216,302,303],{"class":239},"PVC",[216,305,291],{"class":221},[216,307,243],{"class":239},[216,309,291],{"class":221},[216,311,312],{"class":239},"DI",[216,314,291],{"class":221},[216,316,243],{"class":239},[216,318,291],{"class":221},[216,320,321],{"class":239},"CI",[216,323,291],{"class":221},[216,325,243],{"class":239},[216,327,291],{"class":221},[216,329,330],{"class":239},"ST",[216,332,291],{"class":221},[216,334,335],{"class":239},")'",[216,337,263],{"class":229},[216,339,341,343,346,348,351,353,355],{"class":111,"line":340},6,[216,342,236],{"class":229},[216,344,345],{"class":239},"\"R03\"",[216,347,243],{"class":229},[216,349,350],{"class":239},"\"installation year not in the future\"",[216,352,243],{"class":229},[216,354,251],{"class":239},[216,356,254],{"class":229},[216,358,360,363],{"class":111,"line":359},7,[216,361,362],{"class":239},"     '\"install_year\" \u003C= year(now())'",[216,364,263],{"class":229},[216,366,368,370,373,375,378,380,383],{"class":111,"line":367},8,[216,369,236],{"class":229},[216,371,372],{"class":239},"\"R04\"",[216,374,243],{"class":229},[216,376,377],{"class":239},"\"installation year plausible\"",[216,379,243],{"class":229},[216,381,382],{"class":239},"\"warning\"",[216,384,254],{"class":229},[216,386,388,391],{"class":111,"line":387},9,[216,389,390],{"class":239},"     '\"install_year\" >= 1850'",[216,392,263],{"class":229},[216,394,396,398,401,403,406,408,410],{"class":111,"line":395},10,[216,397,236],{"class":229},[216,399,400],{"class":239},"\"R05\"",[216,402,243],{"class":229},[216,404,405],{"class":239},"\"asset id format WP-000000\"",[216,407,243],{"class":229},[216,409,251],{"class":239},[216,411,254],{"class":229},[216,413,415,418,420,423,426,429,431,433],{"class":111,"line":414},11,[216,416,417],{"class":239},"     'regexp_match(\"asset_id\", ",[216,419,291],{"class":221},[216,421,422],{"class":239},"^WP-[0-9]",[216,424,425],{"class":221},"{6}",[216,427,428],{"class":239},"$",[216,430,291],{"class":221},[216,432,335],{"class":239},[216,434,263],{"class":229},[216,436,438,440,443,445,448,450,452],{"class":111,"line":437},12,[216,439,236],{"class":229},[216,441,442],{"class":239},"\"R06\"",[216,444,243],{"class":229},[216,446,447],{"class":239},"\"inspection after installation\"",[216,449,243],{"class":229},[216,451,251],{"class":239},[216,453,254],{"class":229},[216,455,457,460],{"class":111,"line":456},13,[216,458,459],{"class":239},"     '\"last_inspected\" IS NULL OR year(\"last_inspected\") >= \"install_year\"'",[216,461,263],{"class":229},[216,463,465,467,470,472,475,477,479],{"class":111,"line":464},14,[216,466,236],{"class":229},[216,468,469],{"class":239},"\"R07\"",[216,471,243],{"class":229},[216,473,474],{"class":239},"\"mains need a pressure zone\"",[216,476,243],{"class":229},[216,478,382],{"class":239},[216,480,254],{"class":229},[216,482,484,487,489,492,494,497],{"class":111,"line":483},15,[216,485,486],{"class":239},"     '\"pipe_type\" \u003C> ",[216,488,291],{"class":221},[216,490,491],{"class":239},"main",[216,493,291],{"class":221},[216,495,496],{"class":239}," OR \"pressure_zone\" IS NOT NULL'",[216,498,263],{"class":229},[216,500,502],{"class":111,"line":501},16,[216,503,504],{"class":229},"]\n",[14,506,507,511,512,515,516,519,520,523,524,527],{},[508,509,510],"strong",{},"Breakdown:"," Each rule carries an id for reports, a sentence a person can understand, a severity, and the expression. Allowed-value lists use ",[213,513,514],{},"IN",", ranges use comparisons, formats use ",[213,517,518],{},"regexp_match",", and cross-field logic like R06 and R07 reads naturally as \"either the condition does not apply, or it holds\". NULL needs care: ",[213,521,522],{},"\"diameter_mm\" > 0"," is NULL — not true — when the diameter is missing, so a missing value fails R01. That is usually what you want for a required field; for optional ones, write ",[213,525,526],{},"\"x\" IS NULL OR …"," explicitly, as R06 does.",[171,529,531],{"id":530},"evaluate-the-rules-efficiently","Evaluate the rules efficiently",[14,533,534],{},"Each expression is parsed and prepared once, then evaluated for every feature with a shared context. Preparing against the layer's fields lets QGIS resolve field references to indexes in advance.",[206,536,538],{"className":208,"code":537,"language":210,"meta":211,"style":211},"from qgis.core import (QgsProject, QgsExpression, QgsExpressionContext,\n                       QgsExpressionContextUtils)\n\npipes = QgsProject.instance().mapLayersByName(\"water_pipes\")[0]\ncontext = QgsExpressionContext()\ncontext.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(pipes))\n\ncompiled = []\nfor rid, text, severity, expr in RULES:\n    e = QgsExpression(expr)\n    if e.hasParserError():\n        raise ValueError(f\"{rid}: {e.parserErrorString()}\")\n    e.prepare(context)\n    compiled.append((rid, text, severity, e, e.referencedColumns()))\n\nviolations = []\nfor f in pipes.getFeatures():\n    context.setFeature(f)\n    for rid, text, severity, e, cols in compiled:\n        ok = e.evaluate(context)\n        if e.hasEvalError():\n            violations.append((rid, f[\"asset_id\"], f\"eval error: {e.evalErrorString()}\", \"error\"))\n        elif not ok:\n            shown = \", \".join(f\"{c}={f[c]}\" for c in sorted(cols) if c in f.fields().names())\n            violations.append((rid, f[\"asset_id\"], shown, severity))\nprint(len(violations), \"violations\")\n",[213,539,540,554,559,565,586,596,601,605,615,632,642,650,691,696,701,705,714,727,733,747,758,767,800,812,872,882],{"__ignoreMap":211},[216,541,542,545,548,551],{"class":111,"line":218},[216,543,544],{"class":225},"from",[216,546,547],{"class":229}," qgis.core ",[216,549,550],{"class":225},"import",[216,552,553],{"class":229}," (QgsProject, QgsExpression, QgsExpressionContext,\n",[216,555,556],{"class":111,"line":233},[216,557,558],{"class":229},"                       QgsExpressionContextUtils)\n",[216,560,561],{"class":111,"line":257},[216,562,564],{"emptyLinePlaceholder":563},true,"\n",[216,566,567,570,573,576,579,582,584],{"class":111,"line":266},[216,568,569],{"class":229},"pipes ",[216,571,572],{"class":225},"=",[216,574,575],{"class":229}," QgsProject.instance().mapLayersByName(",[216,577,578],{"class":239},"\"water_pipes\"",[216,580,581],{"class":229},")[",[216,583,46],{"class":221},[216,585,504],{"class":229},[216,587,588,591,593],{"class":111,"line":285},[216,589,590],{"class":229},"context ",[216,592,572],{"class":225},[216,594,595],{"class":229}," QgsExpressionContext()\n",[216,597,598],{"class":111,"line":340},[216,599,600],{"class":229},"context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(pipes))\n",[216,602,603],{"class":111,"line":359},[216,604,564],{"emptyLinePlaceholder":563},[216,606,607,610,612],{"class":111,"line":367},[216,608,609],{"class":229},"compiled ",[216,611,572],{"class":225},[216,613,614],{"class":229}," []\n",[216,616,617,620,623,626,629],{"class":111,"line":387},[216,618,619],{"class":225},"for",[216,621,622],{"class":229}," rid, text, severity, expr ",[216,624,625],{"class":225},"in",[216,627,628],{"class":221}," RULES",[216,630,631],{"class":229},":\n",[216,633,634,637,639],{"class":111,"line":395},[216,635,636],{"class":229},"    e ",[216,638,572],{"class":225},[216,640,641],{"class":229}," QgsExpression(expr)\n",[216,643,644,647],{"class":111,"line":414},[216,645,646],{"class":225},"    if",[216,648,649],{"class":229}," e.hasParserError():\n",[216,651,652,655,658,661,664,667,670,673,676,679,681,684,686,688],{"class":111,"line":437},[216,653,654],{"class":225},"        raise",[216,656,657],{"class":221}," ValueError",[216,659,660],{"class":229},"(",[216,662,663],{"class":225},"f",[216,665,666],{"class":239},"\"",[216,668,669],{"class":221},"{",[216,671,672],{"class":229},"rid",[216,674,675],{"class":221},"}",[216,677,678],{"class":239},": ",[216,680,669],{"class":221},[216,682,683],{"class":229},"e.parserErrorString()",[216,685,675],{"class":221},[216,687,666],{"class":239},[216,689,690],{"class":229},")\n",[216,692,693],{"class":111,"line":456},[216,694,695],{"class":229},"    e.prepare(context)\n",[216,697,698],{"class":111,"line":464},[216,699,700],{"class":229},"    compiled.append((rid, text, severity, e, e.referencedColumns()))\n",[216,702,703],{"class":111,"line":483},[216,704,564],{"emptyLinePlaceholder":563},[216,706,707,710,712],{"class":111,"line":501},[216,708,709],{"class":229},"violations ",[216,711,572],{"class":225},[216,713,614],{"class":229},[216,715,717,719,722,724],{"class":111,"line":716},17,[216,718,619],{"class":225},[216,720,721],{"class":229}," f ",[216,723,625],{"class":225},[216,725,726],{"class":229}," pipes.getFeatures():\n",[216,728,730],{"class":111,"line":729},18,[216,731,732],{"class":229},"    context.setFeature(f)\n",[216,734,736,739,742,744],{"class":111,"line":735},19,[216,737,738],{"class":225},"    for",[216,740,741],{"class":229}," rid, text, severity, e, cols ",[216,743,625],{"class":225},[216,745,746],{"class":229}," compiled:\n",[216,748,750,753,755],{"class":111,"line":749},20,[216,751,752],{"class":229},"        ok ",[216,754,572],{"class":225},[216,756,757],{"class":229}," e.evaluate(context)\n",[216,759,761,764],{"class":111,"line":760},21,[216,762,763],{"class":225},"        if",[216,765,766],{"class":229}," e.hasEvalError():\n",[216,768,770,773,776,779,781,784,786,789,791,793,795,797],{"class":111,"line":769},22,[216,771,772],{"class":229},"            violations.append((rid, f[",[216,774,775],{"class":239},"\"asset_id\"",[216,777,778],{"class":229},"], ",[216,780,663],{"class":225},[216,782,783],{"class":239},"\"eval error: ",[216,785,669],{"class":221},[216,787,788],{"class":229},"e.evalErrorString()",[216,790,675],{"class":221},[216,792,666],{"class":239},[216,794,243],{"class":229},[216,796,251],{"class":239},[216,798,799],{"class":229},"))\n",[216,801,803,806,809],{"class":111,"line":802},23,[216,804,805],{"class":225},"        elif",[216,807,808],{"class":225}," not",[216,810,811],{"class":229}," ok:\n",[216,813,815,818,820,823,826,828,830,832,835,837,839,841,844,846,848,851,854,856,859,862,865,867,869],{"class":111,"line":814},24,[216,816,817],{"class":229},"            shown ",[216,819,572],{"class":225},[216,821,822],{"class":239}," \", \"",[216,824,825],{"class":229},".join(",[216,827,663],{"class":225},[216,829,666],{"class":239},[216,831,669],{"class":221},[216,833,834],{"class":229},"c",[216,836,675],{"class":221},[216,838,572],{"class":239},[216,840,669],{"class":221},[216,842,843],{"class":229},"f[c]",[216,845,675],{"class":221},[216,847,666],{"class":239},[216,849,850],{"class":225}," for",[216,852,853],{"class":229}," c ",[216,855,625],{"class":225},[216,857,858],{"class":221}," sorted",[216,860,861],{"class":229},"(cols) ",[216,863,864],{"class":225},"if",[216,866,853],{"class":229},[216,868,625],{"class":225},[216,870,871],{"class":229}," f.fields().names())\n",[216,873,875,877,879],{"class":111,"line":874},25,[216,876,772],{"class":229},[216,878,775],{"class":239},[216,880,881],{"class":229},"], shown, severity))\n",[216,883,885,888,890,893,896,899],{"class":111,"line":884},26,[216,886,887],{"class":221},"print",[216,889,660],{"class":229},[216,891,892],{"class":221},"len",[216,894,895],{"class":229},"(violations), ",[216,897,898],{"class":239},"\"violations\"",[216,900,690],{"class":229},[14,902,903,905,906,909,910,913,914,917,918,921],{},[508,904,510],{}," Checking ",[213,907,908],{},"hasParserError()"," up front turns a typo in a rule into an immediate, named failure rather than thousands of evaluation errors. ",[213,911,912],{},"prepare()"," once per rule and ",[213,915,916],{},"setFeature()"," once per feature is the efficient pattern; reconstructing expressions inside the loop is the usual cause of slow validators. ",[213,919,920],{},"referencedColumns()"," tells you which fields each rule reads, so the violation can show exactly the values that failed rather than the whole row. An evaluation error — a function applied to the wrong type — is recorded as a violation in its own right, because it usually means the data contains something the rule's author never expected.",[171,923,925],{"id":924},"check-references-against-lookup-tables","Check references against lookup tables",[14,927,928,929,932],{},"Many attributes are codes that must exist in another table: a zoning code, a species code, a street id. An expression can do this with ",[213,930,931],{},"attribute(get_feature(…))",", but for large tables a Python set is far faster.",[14,934,935],{},[29,936,939,942,945,948,955,958,965,971,974,978,983,987,991,994,997,1003,1007,1012,1017,1021,1023,1027],{"viewBox":937,"role":32,"ariaLabel":938,"xmlns":34},"0 0 760 216","A lookup table loaded once into a set, each feature's code tested for membership, unknown codes reported as violations and NULL codes as missing",[36,940,941],{},"Codes checked against a lookup set",[40,943,944],{},"The lookup table, for example species codes, is read once into a Python set. Each feature's code is tested for membership, which takes constant time. Codes not in the set become violations; NULL codes are reported separately as missing rather than invalid. Case and whitespace are normalised before the test so that trailing spaces do not create false violations.",[44,946],{"x":46,"y":46,"width":47,"height":947,"fill":49},"216",[51,949,950],{},[54,951,953],{"id":952,"viewBox":57,"refX":58,"refY":59,"markerWidth":60,"markerHeight":60,"orient":61},"attrRefArrow",[63,954],{"d":65,"fill":66},[68,956,957],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"Load once, test in constant time",[44,959],{"x":78,"y":960,"width":961,"height":962,"rx":58,"fill":963,"stroke":964,"style":84},"60","220","120","#e8efe6","#15803d",[68,966,970],{"x":967,"y":968,"style":129,"fill":969,"textAnchor":74},"134","97.78","#166534","lookup table",[68,972,973],{"x":967,"y":157,"style":134,"fill":66,"textAnchor":74},"species_codes",[68,975,977],{"x":967,"y":976,"style":134,"fill":108,"textAnchor":74},"149.78","read once",[111,979],{"x1":980,"y1":962,"x2":981,"y2":962,"stroke":66,"style":982},"244","290","stroke-width:1.8;marker-end:url(#attrRefArrow)",[44,984],{"x":985,"y":960,"width":986,"height":962,"rx":58,"fill":123,"stroke":124,"style":84},"298","180",[68,988,990],{"x":989,"y":968,"style":129,"fill":124,"textAnchor":74},"388","Python set",[68,992,993],{"x":989,"y":157,"style":134,"fill":108,"textAnchor":74},"normalised",[68,995,996],{"x":989,"y":976,"style":134,"fill":108,"textAnchor":74},"strip · upper",[111,998],{"x1":999,"y1":1000,"x2":1001,"y2":1002,"stroke":66,"style":982},"478","100","526","76",[111,1004],{"x1":999,"y1":1005,"x2":1001,"y2":1006,"stroke":66,"style":982},"140","164",[44,1008],{"x":1009,"y":79,"width":1010,"height":960,"rx":58,"fill":148,"stroke":1011,"style":84},"534","202","#b91c1c",[68,1013,1016],{"x":1014,"y":1015,"style":129,"fill":1011,"textAnchor":74},"635","75.805","unknown code",[68,1018,1020],{"x":1014,"y":1019,"style":134,"fill":108,"textAnchor":74},"95.755","violation",[44,1022],{"x":1009,"y":1005,"width":1010,"height":960,"rx":58,"fill":148,"stroke":149,"style":84},[68,1024,1026],{"x":1014,"y":1025,"style":129,"fill":149,"textAnchor":74},"163.805","NULL code",[68,1028,1030],{"x":1014,"y":1029,"style":134,"fill":108,"textAnchor":74},"183.755","missing, reported apart",[206,1032,1034],{"className":208,"code":1033,"language":210,"meta":211,"style":211},"species = QgsProject.instance().mapLayersByName(\"species_codes\")[0]\nvalid_codes = {str(f[\"code\"]).strip().upper() for f in species.getFeatures()}\n\ntrees = QgsProject.instance().mapLayersByName(\"street_trees\")[0]\nfor f in trees.getFeatures():\n    code = f[\"species_code\"]\n    if code is None or (hasattr(code, \"isNull\") and code.isNull()) or not str(code).strip():\n        violations.append((\"R10\", f[\"tree_id\"], \"species_code missing\", \"warning\"))\n    elif str(code).strip().upper() not in valid_codes:\n        violations.append((\"R10\", f[\"tree_id\"], f\"species_code={code!r} not in lookup\", \"error\"))\n",[213,1035,1036,1054,1085,1089,1107,1118,1133,1181,1206,1225],{"__ignoreMap":211},[216,1037,1038,1041,1043,1045,1048,1050,1052],{"class":111,"line":218},[216,1039,1040],{"class":229},"species ",[216,1042,572],{"class":225},[216,1044,575],{"class":229},[216,1046,1047],{"class":239},"\"species_codes\"",[216,1049,581],{"class":229},[216,1051,46],{"class":221},[216,1053,504],{"class":229},[216,1055,1056,1059,1061,1064,1067,1070,1073,1076,1078,1080,1082],{"class":111,"line":233},[216,1057,1058],{"class":229},"valid_codes ",[216,1060,572],{"class":225},[216,1062,1063],{"class":229}," {",[216,1065,1066],{"class":221},"str",[216,1068,1069],{"class":229},"(f[",[216,1071,1072],{"class":239},"\"code\"",[216,1074,1075],{"class":229},"]).strip().upper() ",[216,1077,619],{"class":225},[216,1079,721],{"class":229},[216,1081,625],{"class":225},[216,1083,1084],{"class":229}," species.getFeatures()}\n",[216,1086,1087],{"class":111,"line":257},[216,1088,564],{"emptyLinePlaceholder":563},[216,1090,1091,1094,1096,1098,1101,1103,1105],{"class":111,"line":266},[216,1092,1093],{"class":229},"trees ",[216,1095,572],{"class":225},[216,1097,575],{"class":229},[216,1099,1100],{"class":239},"\"street_trees\"",[216,1102,581],{"class":229},[216,1104,46],{"class":221},[216,1106,504],{"class":229},[216,1108,1109,1111,1113,1115],{"class":111,"line":285},[216,1110,619],{"class":225},[216,1112,721],{"class":229},[216,1114,625],{"class":225},[216,1116,1117],{"class":229}," trees.getFeatures():\n",[216,1119,1120,1123,1125,1128,1131],{"class":111,"line":340},[216,1121,1122],{"class":229},"    code ",[216,1124,572],{"class":225},[216,1126,1127],{"class":229}," f[",[216,1129,1130],{"class":239},"\"species_code\"",[216,1132,504],{"class":229},[216,1134,1135,1137,1140,1143,1146,1149,1152,1155,1158,1161,1164,1167,1170,1173,1175,1178],{"class":111,"line":359},[216,1136,646],{"class":225},[216,1138,1139],{"class":229}," code ",[216,1141,1142],{"class":225},"is",[216,1144,1145],{"class":221}," None",[216,1147,1148],{"class":225}," or",[216,1150,1151],{"class":229}," (",[216,1153,1154],{"class":221},"hasattr",[216,1156,1157],{"class":229},"(code, ",[216,1159,1160],{"class":239},"\"isNull\"",[216,1162,1163],{"class":229},") ",[216,1165,1166],{"class":225},"and",[216,1168,1169],{"class":229}," code.isNull()) ",[216,1171,1172],{"class":225},"or",[216,1174,808],{"class":225},[216,1176,1177],{"class":221}," str",[216,1179,1180],{"class":229},"(code).strip():\n",[216,1182,1183,1186,1189,1192,1195,1197,1200,1202,1204],{"class":111,"line":367},[216,1184,1185],{"class":229},"        violations.append((",[216,1187,1188],{"class":239},"\"R10\"",[216,1190,1191],{"class":229},", f[",[216,1193,1194],{"class":239},"\"tree_id\"",[216,1196,778],{"class":229},[216,1198,1199],{"class":239},"\"species_code missing\"",[216,1201,243],{"class":229},[216,1203,382],{"class":239},[216,1205,799],{"class":229},[216,1207,1208,1211,1213,1216,1219,1222],{"class":111,"line":387},[216,1209,1210],{"class":225},"    elif",[216,1212,1177],{"class":221},[216,1214,1215],{"class":229},"(code).strip().upper() ",[216,1217,1218],{"class":225},"not",[216,1220,1221],{"class":225}," in",[216,1223,1224],{"class":229}," valid_codes:\n",[216,1226,1227,1229,1231,1233,1235,1237,1239,1242,1244,1246,1249,1251,1254,1256,1258],{"class":111,"line":395},[216,1228,1185],{"class":229},[216,1230,1188],{"class":239},[216,1232,1191],{"class":229},[216,1234,1194],{"class":239},[216,1236,778],{"class":229},[216,1238,663],{"class":225},[216,1240,1241],{"class":239},"\"species_code=",[216,1243,669],{"class":221},[216,1245,213],{"class":229},[216,1247,1248],{"class":225},"!r",[216,1250,675],{"class":221},[216,1252,1253],{"class":239}," not in lookup\"",[216,1255,243],{"class":229},[216,1257,251],{"class":239},[216,1259,799],{"class":229},[14,1261,1262,1264,1265,1269],{},[508,1263,510],{}," Normalising both sides — stripping whitespace and upper-casing — prevents a trailing space from producing hundreds of false violations, which is the fastest way to make people ignore a report. Separating missing from unknown matters because they have different causes: missing usually means incomplete capture, unknown usually means a typo or an outdated code list. Where the lookup is a related table in the project, ",[21,1266,1268],{"href":1267},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002Fdefine-layer-relations-pyqgis\u002F","defining layer relations"," and a value-relation widget prevent these errors at entry.",[171,1271,1273],{"id":1272},"write-violations-to-a-table","Write violations to a table",[14,1275,1276],{},"A list in the console is fine for a first look; a table is what a team works through. An attribute-only memory layer turns the list into something that can be filtered, sorted, joined back to the source layer and saved.",[206,1278,1280],{"className":208,"code":1279,"language":210,"meta":211,"style":211},"from qgis.core import QgsVectorLayer, QgsFeature\n\ntable = QgsVectorLayer(\n    \"None?field=rule_id:string(8)&field=rule:string(120)&field=asset_id:string(20)\"\n    \"&field=detail:string(250)&field=severity:string(10)\", \"violations\", \"memory\")\nrule_text = {rid: text for rid, text, *_ in RULES}\nrule_text[\"R10\"] = \"species code exists in lookup\"\n\nrows = []\nfor rid, key, detail, severity in violations:\n    f = QgsFeature(table.fields())\n    f.setAttributes([rid, rule_text.get(rid, \"\"), str(key), detail[:250], severity])\n    rows.append(f)\ntable.dataProvider().addFeatures(rows)\nQgsProject.instance().addMapLayer(table)\n",[213,1281,1282,1293,1297,1307,1312,1328,1356,1371,1375,1384,1396,1406,1427,1432,1437],{"__ignoreMap":211},[216,1283,1284,1286,1288,1290],{"class":111,"line":218},[216,1285,544],{"class":225},[216,1287,547],{"class":229},[216,1289,550],{"class":225},[216,1291,1292],{"class":229}," QgsVectorLayer, QgsFeature\n",[216,1294,1295],{"class":111,"line":233},[216,1296,564],{"emptyLinePlaceholder":563},[216,1298,1299,1302,1304],{"class":111,"line":257},[216,1300,1301],{"class":229},"table ",[216,1303,572],{"class":225},[216,1305,1306],{"class":229}," QgsVectorLayer(\n",[216,1308,1309],{"class":111,"line":266},[216,1310,1311],{"class":239},"    \"None?field=rule_id:string(8)&field=rule:string(120)&field=asset_id:string(20)\"\n",[216,1313,1314,1317,1319,1321,1323,1326],{"class":111,"line":285},[216,1315,1316],{"class":239},"    \"&field=detail:string(250)&field=severity:string(10)\"",[216,1318,243],{"class":229},[216,1320,898],{"class":239},[216,1322,243],{"class":229},[216,1324,1325],{"class":239},"\"memory\"",[216,1327,690],{"class":229},[216,1329,1330,1333,1335,1338,1340,1343,1346,1349,1351,1353],{"class":111,"line":340},[216,1331,1332],{"class":229},"rule_text ",[216,1334,572],{"class":225},[216,1336,1337],{"class":229}," {rid: text ",[216,1339,619],{"class":225},[216,1341,1342],{"class":229}," rid, text, ",[216,1344,1345],{"class":225},"*",[216,1347,1348],{"class":229},"_ ",[216,1350,625],{"class":225},[216,1352,628],{"class":221},[216,1354,1355],{"class":229},"}\n",[216,1357,1358,1361,1363,1366,1368],{"class":111,"line":359},[216,1359,1360],{"class":229},"rule_text[",[216,1362,1188],{"class":239},[216,1364,1365],{"class":229},"] ",[216,1367,572],{"class":225},[216,1369,1370],{"class":239}," \"species code exists in lookup\"\n",[216,1372,1373],{"class":111,"line":367},[216,1374,564],{"emptyLinePlaceholder":563},[216,1376,1377,1380,1382],{"class":111,"line":387},[216,1378,1379],{"class":229},"rows ",[216,1381,572],{"class":225},[216,1383,614],{"class":229},[216,1385,1386,1388,1391,1393],{"class":111,"line":395},[216,1387,619],{"class":225},[216,1389,1390],{"class":229}," rid, key, detail, severity ",[216,1392,625],{"class":225},[216,1394,1395],{"class":229}," violations:\n",[216,1397,1398,1401,1403],{"class":111,"line":414},[216,1399,1400],{"class":229},"    f ",[216,1402,572],{"class":225},[216,1404,1405],{"class":229}," QgsFeature(table.fields())\n",[216,1407,1408,1411,1414,1417,1419,1422,1424],{"class":111,"line":437},[216,1409,1410],{"class":229},"    f.setAttributes([rid, rule_text.get(rid, ",[216,1412,1413],{"class":239},"\"\"",[216,1415,1416],{"class":229},"), ",[216,1418,1066],{"class":221},[216,1420,1421],{"class":229},"(key), detail[:",[216,1423,80],{"class":221},[216,1425,1426],{"class":229},"], severity])\n",[216,1428,1429],{"class":111,"line":456},[216,1430,1431],{"class":229},"    rows.append(f)\n",[216,1433,1434],{"class":111,"line":464},[216,1435,1436],{"class":229},"table.dataProvider().addFeatures(rows)\n",[216,1438,1439],{"class":111,"line":483},[216,1440,1441],{"class":229},"QgsProject.instance().addMapLayer(table)\n",[14,1443,1444,1446,1447,1450,1451,1455],{},[508,1445,510],{}," Keeping the rule text in each row makes the table self-explanatory when it is exported to a spreadsheet for someone who never sees the script. Truncating details protects against unexpectedly long values breaking a fixed-length field. To see violations on the map, join the table to the source layer on ",[213,1448,1449],{},"asset_id"," — the ",[21,1452,1454],{"href":1453},"\u002Fspatial-data-processing-automation\u002Fattribute-tables-and-field-management\u002Fjoin-attributes-by-field-value-pyqgis\u002F","attribute join recipe"," shows how — or select the offending features by key and zoom to them.",[171,1457,1459],{"id":1458},"summarise-by-rule-and-severity","Summarise by rule and severity",[14,1461,1462],{},"The summary is what goes into an email or a dashboard; the table is what people fix from.",[14,1464,1465],{},[29,1466,1469,1472,1475,1477,1480,1486,1493,1500,1504,1508,1513,1516,1520,1525,1529,1533,1538,1541,1545,1549],{"viewBox":1467,"role":32,"ariaLabel":1468,"xmlns":34},"0 0 760 250","Bar chart of violation counts by rule, with the asset id format rule failing most often",[36,1470,1471],{},"Violation counts by rule",[40,1473,1474],{},"A horizontal bar chart of violations by rule for a sample water pipe layer. R05 asset id format has the most errors, followed by R02 material list and R06 inspection date order; R04 and R07 are warnings with smaller counts. The chart shows how a summary directs attention to the rules with the most failures.",[44,1476],{"x":46,"y":46,"width":47,"height":80,"fill":49},[68,1478,1479],{"x":70,"y":71,"style":72,"fill":73,"textAnchor":74},"Where the failures concentrate",[68,1481,1485],{"x":114,"y":1482,"style":1483,"fill":66,"textAnchor":1484},"70","text-anchor:end;font-size:10.5px;font-family:sans-serif","end","R05 id format",[44,1487],{"x":121,"y":1488,"width":1489,"height":1490,"rx":1491,"fill":1011,"stroke":1011,"style":1492},"58","500","18","3","stroke-width:1.5",[68,1494,1499],{"x":1495,"y":1496,"style":1497,"fill":66,"textAnchor":1498},"670","72","text-anchor:start;font-size:10.5px;font-family:sans-serif","start","412",[68,1501,1503],{"x":114,"y":1502,"style":1483,"fill":66,"textAnchor":1484},"102","R02 material",[44,1505],{"x":121,"y":1506,"width":1507,"height":1490,"rx":1491,"fill":1011,"stroke":1011,"style":1492},"90","277",[68,1509,1512],{"x":1510,"y":1511,"style":1497,"fill":66,"textAnchor":1498},"447","104","228",[68,1514,1515],{"x":114,"y":967,"style":1483,"fill":66,"textAnchor":1484},"R06 dates",[44,1517],{"x":121,"y":1518,"width":1519,"height":1490,"rx":1491,"fill":1011,"stroke":1011,"style":1492},"122","114",[68,1521,1524],{"x":1522,"y":1523,"style":1497,"fill":66,"textAnchor":1498},"284","136","94",[68,1526,1528],{"x":114,"y":1527,"style":1483,"fill":66,"textAnchor":1484},"166","R04 plausible",[44,1530],{"x":121,"y":1531,"width":1532,"height":1490,"rx":1491,"fill":149,"stroke":149,"style":1492},"154","69",[68,1534,1537],{"x":1535,"y":1536,"style":1497,"fill":66,"textAnchor":1498},"239","168","57 (warning)",[68,1539,1540],{"x":114,"y":147,"style":1483,"fill":66,"textAnchor":1484},"R07 zone",[44,1542],{"x":121,"y":1543,"width":1544,"height":1490,"rx":1491,"fill":149,"stroke":149,"style":1492},"186","26",[68,1546,1548],{"x":81,"y":1547,"style":1497,"fill":66,"textAnchor":1498},"200","21 (warning)",[68,1550,1552],{"x":70,"y":1551,"style":107,"fill":108,"textAnchor":74},"236","illustrative counts — errors in red, warnings in amber",[206,1554,1556],{"className":208,"code":1555,"language":210,"meta":211,"style":211},"from collections import Counter\n\nby_rule = Counter((rid, sev) for rid, _, _, sev in violations)\nfeatures_failing = len({key for _, key, _, sev in violations if sev == \"error\"})\nprint(f\"{features_failing} of {pipes.featureCount()} features fail at least one error rule\")\nfor (rid, sev), n in sorted(by_rule.items(), key=lambda kv: -kv[1]):\n    print(f\"{rid:\u003C4} {sev:\u003C8} {n:>6}  {rule_text.get(rid, '')}\")\n",[213,1557,1558,1570,1574,1594,1631,1663,1699],{"__ignoreMap":211},[216,1559,1560,1562,1565,1567],{"class":111,"line":218},[216,1561,544],{"class":225},[216,1563,1564],{"class":229}," collections ",[216,1566,550],{"class":225},[216,1568,1569],{"class":229}," Counter\n",[216,1571,1572],{"class":111,"line":233},[216,1573,564],{"emptyLinePlaceholder":563},[216,1575,1576,1579,1581,1584,1586,1589,1591],{"class":111,"line":257},[216,1577,1578],{"class":229},"by_rule ",[216,1580,572],{"class":225},[216,1582,1583],{"class":229}," Counter((rid, sev) ",[216,1585,619],{"class":225},[216,1587,1588],{"class":229}," rid, _, _, sev ",[216,1590,625],{"class":225},[216,1592,1593],{"class":229}," violations)\n",[216,1595,1596,1599,1601,1604,1607,1609,1612,1614,1617,1619,1622,1625,1628],{"class":111,"line":266},[216,1597,1598],{"class":229},"features_failing ",[216,1600,572],{"class":225},[216,1602,1603],{"class":221}," len",[216,1605,1606],{"class":229},"({key ",[216,1608,619],{"class":225},[216,1610,1611],{"class":229}," _, key, _, sev ",[216,1613,625],{"class":225},[216,1615,1616],{"class":229}," violations ",[216,1618,864],{"class":225},[216,1620,1621],{"class":229}," sev ",[216,1623,1624],{"class":225},"==",[216,1626,1627],{"class":239}," \"error\"",[216,1629,1630],{"class":229},"})\n",[216,1632,1633,1635,1637,1639,1641,1643,1646,1648,1651,1653,1656,1658,1661],{"class":111,"line":285},[216,1634,887],{"class":221},[216,1636,660],{"class":229},[216,1638,663],{"class":225},[216,1640,666],{"class":239},[216,1642,669],{"class":221},[216,1644,1645],{"class":229},"features_failing",[216,1647,675],{"class":221},[216,1649,1650],{"class":239}," of ",[216,1652,669],{"class":221},[216,1654,1655],{"class":229},"pipes.featureCount()",[216,1657,675],{"class":221},[216,1659,1660],{"class":239}," features fail at least one error rule\"",[216,1662,690],{"class":229},[216,1664,1665,1667,1670,1672,1674,1677,1681,1684,1687,1690,1693,1696],{"class":111,"line":340},[216,1666,619],{"class":225},[216,1668,1669],{"class":229}," (rid, sev), n ",[216,1671,625],{"class":225},[216,1673,858],{"class":221},[216,1675,1676],{"class":229},"(by_rule.items(), ",[216,1678,1680],{"class":1679},"s9osk","key",[216,1682,1683],{"class":225},"=lambda",[216,1685,1686],{"class":229}," kv: ",[216,1688,1689],{"class":225},"-",[216,1691,1692],{"class":229},"kv[",[216,1694,1695],{"class":221},"1",[216,1697,1698],{"class":229},"]):\n",[216,1700,1701,1704,1706,1708,1710,1712,1714,1717,1719,1721,1724,1727,1729,1731,1734,1737,1739,1742,1745,1748,1751,1753,1755],{"class":111,"line":359},[216,1702,1703],{"class":221},"    print",[216,1705,660],{"class":229},[216,1707,663],{"class":225},[216,1709,666],{"class":239},[216,1711,669],{"class":221},[216,1713,672],{"class":229},[216,1715,1716],{"class":225},":\u003C4",[216,1718,675],{"class":221},[216,1720,1063],{"class":221},[216,1722,1723],{"class":229},"sev",[216,1725,1726],{"class":225},":\u003C8",[216,1728,675],{"class":221},[216,1730,1063],{"class":221},[216,1732,1733],{"class":229},"n",[216,1735,1736],{"class":225},":>6",[216,1738,675],{"class":221},[216,1740,1741],{"class":221},"  {",[216,1743,1744],{"class":229},"rule_text.get(rid, ",[216,1746,1747],{"class":239},"''",[216,1749,1750],{"class":229},")",[216,1752,675],{"class":221},[216,1754,666],{"class":239},[216,1756,690],{"class":229},[14,1758,1759,1761],{},[508,1760,510],{}," The number of features failing at least one error rule is the headline that best describes overall quality; total violations overstates it when a single bad feature fails several rules. Sorting rules by count shows where effort pays off: a format rule failing on four hundred features usually has one systematic cause — an import that dropped the prefix — that can be fixed with a single update rather than four hundred edits.",[171,1763,1765],{"id":1764},"keep-rules-outside-the-script","Keep rules outside the script",[14,1767,1768],{},"Rules change more often than code. Storing them in a CSV or a table in the project's GeoPackage lets data stewards maintain them without touching Python.",[206,1770,1772],{"className":208,"code":1771,"language":210,"meta":211,"style":211},"import csv\n\ndef load_rules(path):\n    with open(path, newline=\"\", encoding=\"utf-8\") as fh:\n        return [(r[\"id\"], r[\"description\"], r[\"severity\"], r[\"expression\"])\n                for r in csv.DictReader(fh) if r.get(\"active\", \"1\") == \"1\"]\n\nRULES = load_rules(\"\u002Fdata\u002Fqa\u002Frules_water_pipes.csv\")\n",[213,1773,1774,1781,1785,1797,1833,1863,1898,1902],{"__ignoreMap":211},[216,1775,1776,1778],{"class":111,"line":218},[216,1777,550],{"class":225},[216,1779,1780],{"class":229}," csv\n",[216,1782,1783],{"class":111,"line":233},[216,1784,564],{"emptyLinePlaceholder":563},[216,1786,1787,1790,1794],{"class":111,"line":257},[216,1788,1789],{"class":225},"def",[216,1791,1793],{"class":1792},"svObZ"," load_rules",[216,1795,1796],{"class":229},"(path):\n",[216,1798,1799,1802,1805,1808,1811,1813,1815,1817,1820,1822,1825,1827,1830],{"class":111,"line":266},[216,1800,1801],{"class":225},"    with",[216,1803,1804],{"class":221}," open",[216,1806,1807],{"class":229},"(path, ",[216,1809,1810],{"class":1679},"newline",[216,1812,572],{"class":225},[216,1814,1413],{"class":239},[216,1816,243],{"class":229},[216,1818,1819],{"class":1679},"encoding",[216,1821,572],{"class":225},[216,1823,1824],{"class":239},"\"utf-8\"",[216,1826,1163],{"class":229},[216,1828,1829],{"class":225},"as",[216,1831,1832],{"class":229}," fh:\n",[216,1834,1835,1838,1841,1844,1847,1850,1852,1855,1857,1860],{"class":111,"line":285},[216,1836,1837],{"class":225},"        return",[216,1839,1840],{"class":229}," [(r[",[216,1842,1843],{"class":239},"\"id\"",[216,1845,1846],{"class":229},"], r[",[216,1848,1849],{"class":239},"\"description\"",[216,1851,1846],{"class":229},[216,1853,1854],{"class":239},"\"severity\"",[216,1856,1846],{"class":229},[216,1858,1859],{"class":239},"\"expression\"",[216,1861,1862],{"class":229},"])\n",[216,1864,1865,1868,1871,1873,1876,1878,1881,1884,1886,1889,1891,1893,1896],{"class":111,"line":340},[216,1866,1867],{"class":225},"                for",[216,1869,1870],{"class":229}," r ",[216,1872,625],{"class":225},[216,1874,1875],{"class":229}," csv.DictReader(fh) ",[216,1877,864],{"class":225},[216,1879,1880],{"class":229}," r.get(",[216,1882,1883],{"class":239},"\"active\"",[216,1885,243],{"class":229},[216,1887,1888],{"class":239},"\"1\"",[216,1890,1163],{"class":229},[216,1892,1624],{"class":225},[216,1894,1895],{"class":239}," \"1\"",[216,1897,504],{"class":229},[216,1899,1900],{"class":111,"line":359},[216,1901,564],{"emptyLinePlaceholder":563},[216,1903,1904,1906,1908,1911,1914],{"class":111,"line":367},[216,1905,222],{"class":221},[216,1907,226],{"class":225},[216,1909,1910],{"class":229}," load_rules(",[216,1912,1913],{"class":239},"\"\u002Fdata\u002Fqa\u002Frules_water_pipes.csv\"",[216,1915,690],{"class":229},[14,1917,1918,1920,1921,1924,1925,1929],{},[508,1919,510],{}," A rules file with an ",[213,1922,1923],{},"active"," column lets a rule be switched off temporarily without deleting it, which is useful while a known problem is being fixed. Because the expressions are plain text, a rules file can be reviewed in a pull request, versioned with the data model, and reused across layers that share fields. The ",[21,1926,1928],{"href":1927},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fbuild-data-quality-report-pyqgis\u002F","data quality report"," recipe reads rules this way and combines them with geometry and topology checks.",[171,1931,1933],{"id":1932},"qgis-version-compatibility","QGIS version compatibility",[14,1935,1936,1937,1939,1940,1943,1944,1947],{},"Expression evaluation and ",[213,1938,518],{}," behave the same on QGIS 3.x and QGIS 4. ",[213,1941,1942],{},"QgsExpressionContextUtils.globalProjectLayerScopes"," exists since 3.0. On QGIS 4, NULL attribute values arrive as ",[213,1945,1946],{},"None",", which the checks above already handle.",[171,1949,1951],{"id":1950},"troubleshooting","Troubleshooting",[176,1953,1954,1966,1976,1986],{},[179,1955,1956,1959,1960,1962,1963,192],{},[508,1957,1958],{},"Every feature fails a rule."," The field name is misspelled; an unknown field evaluates to NULL. Check ",[213,1961,920],{}," against ",[213,1964,1965],{},"layer.fields().names()",[179,1967,1968,1971,1972,1975],{},[508,1969,1970],{},"Date rules raise evaluation errors."," The field is text, not a date; wrap it in ",[213,1973,1974],{},"to_date()"," in the rule.",[179,1977,1978,1981,1982,1985],{},[508,1979,1980],{},"The validator is slow."," Expressions are being created inside the loop, or a rule uses ",[213,1983,1984],{},"get_feature"," against a large table; use a Python set for lookups.",[179,1987,1988,1991],{},[508,1989,1990],{},"Hundreds of false lookup violations."," Codes differ in case or whitespace; normalise both sides.",[171,1993,1995],{"id":1994},"conclusion","Conclusion",[14,1997,1998],{},"Write each rule as a QGIS expression that is true when a feature passes, prepare them once and evaluate with a shared context, check codes against lookup sets, collect violations into a table with rule, key, value and severity, summarise by features failing and by rule, and keep the rules in a file data stewards can edit.",[171,2000,2002],{"id":2001},"frequently-asked-questions","Frequently Asked Questions",[14,2004,2005,2008],{},[508,2006,2007],{},"Can I validate while people edit instead of afterwards?","\nYes. Field constraints with expressions enforce rules in the attribute form; scripted validation then becomes a safety net for imports and bulk edits.",[14,2010,2011,2014,2015,2018,2019,192],{},[508,2012,2013],{},"How do I validate geometry and attributes together?","\nUse geometry functions inside rules, for example ",[213,2016,2017],{},"$area > 10"," or ",[213,2020,2021],{},"is_valid($geometry)",[14,2023,2024,2027],{},[508,2025,2026],{},"Should warnings block a data release?","\nUsually not. Errors block; warnings are reviewed and either fixed or accepted with a note.",[14,2029,2030,2033,2034,2018,2037,2040],{},[508,2031,2032],{},"Can rules reference other layers?","\nYes, through ",[213,2035,2036],{},"aggregate()",[213,2038,2039],{},"get_feature()"," in the expression, at a performance cost on large layers.",[171,2042,2044],{"id":2043},"related","Related",[176,2046,2047,2052,2058,2063,2068],{},[179,2048,2049,2051],{},[21,2050,24],{"href":23}," — the guide this recipe belongs to",[179,2053,2054],{},[21,2055,2057],{"href":2056},"\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Ffind-duplicate-geometries-and-features-pyqgis\u002F","Find Duplicate Geometries and Features in PyQGIS",[179,2059,2060],{},[21,2061,2062],{"href":1927},"Build a Data Quality Report in PyQGIS",[179,2064,2065],{},[21,2066,2067],{"href":190},"Evaluate a QGIS Expression in PyQGIS",[179,2069,2070],{},[21,2071,2073],{"href":2072},"\u002Fqgis-plugin-development\u002Fattribute-forms-and-layer-actions\u002Fset-default-values-and-field-constraints-pyqgis\u002F","Set Default Values and Field Constraints in PyQGIS",[2075,2076,2077],"style",{},"html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}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 .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":211,"searchDepth":233,"depth":233,"links":2079},[2080,2081,2082,2083,2084,2085,2086,2087,2088,2089,2090,2091],{"id":173,"depth":233,"text":174},{"id":195,"depth":233,"text":196},{"id":530,"depth":233,"text":531},{"id":924,"depth":233,"text":925},{"id":1272,"depth":233,"text":1273},{"id":1458,"depth":233,"text":1459},{"id":1764,"depth":233,"text":1765},{"id":1932,"depth":233,"text":1933},{"id":1950,"depth":233,"text":1951},{"id":1994,"depth":233,"text":1995},{"id":2001,"depth":233,"text":2002},{"id":2043,"depth":233,"text":2044},"Check a layer's attribute table against explicit rules — required fields, allowed values, numeric ranges, patterns, cross-field logic and references to lookup tables — using QGIS expressions, and write every violation to a reviewable error table.","md",{"slug":2095,"type":2096,"breadcrumb":2097,"datePublished":2098,"dateModified":2098},"validate-attribute-values-against-rules-pyqgis","article","Validate Attribute Values","2026-10-02","\u002Fspatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fvalidate-attribute-values-against-rules-pyqgis",{"title":5,"description":2092},"spatial-data-processing-automation\u002Fdata-quality-and-topology-validation\u002Fvalidate-attribute-values-against-rules-pyqgis\u002Findex","P2c_LbRcval21hOSNScbNJWp5yvh9JoUaqmEUAqaRg4",1790966261554]