[{"data":1,"prerenderedAt":1471},["ShallowReactive",2],{"doc:\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002Fuse-project-variables-and-metadata-pyqgis":3},{"id":4,"title":5,"body":6,"description":1460,"extension":1461,"meta":1462,"navigation":201,"path":1467,"seo":1468,"stem":1469,"__hash__":1470},"docs\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002Fuse-project-variables-and-metadata-pyqgis\u002Findex.md","Use Project Variables and Metadata in PyQGIS",{"type":7,"value":8,"toc":1448},"minimark",[9,13,17,26,128,133,164,168,350,378,381,420,430,434,437,503,526,533,637,641,648,802,819,915,918,1057,1068,1072,1075,1204,1213,1217,1282,1297,1301,1359,1363,1366,1370,1380,1386,1399,1405,1411,1415,1444],[10,11,5],"h1",{"id":12},"use-project-variables-and-metadata-in-pyqgis",[14,15,16],"p",{},"The date of a survey round appears in a layout title, in a label rule, in a layer filter and in the exported file name. Hard-coded, that is four places to update every quarter and four chances to miss one. As a project variable it is one assignment, and everything downstream reads the same value through the expression engine.",[14,18,19,20,25],{},"This recipe belongs to ",[21,22,24],"a",{"href":23},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002F","Working with QGIS Projects in PyQGIS",". It covers setting and reading project variables from Python, the scope rules that decide which variable wins, evaluating an expression that uses them, and filling in the project metadata record that matters as soon as a project is shared.",[14,27,28],{},[29,30,35,39,43,50,59,69,74,82,86,95,99,106,110,119,123],"svg",{"viewBox":31,"role":32,"ariaLabel":33,"xmlns":34},"0 0 760 268","img","Diagram of expression variable scopes stacked from global through application, project, layer and feature, with a note that the innermost definition of a name wins","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg",[36,37,38],"title",{},"Variable scopes, innermost wins",[40,41,42],"desc",{},"Five nested scopes are stacked: global variables set in user settings, then application scope, then project scope stored in the project file, then layer scope, then the feature being evaluated. When the same variable name is defined in more than one scope, the innermost definition is the one an expression sees.",[44,45],"rect",{"x":46,"y":46,"width":47,"height":48,"fill":49},"0","760","268","#f6f3ea",[51,52,58],"text",{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"380","28","text-anchor:middle;font-size:14px;font-weight:bold;font-family:sans-serif","#17211d","middle","The same name can exist five times — the innermost one wins",[44,60],{"x":61,"y":62,"width":63,"height":64,"rx":65,"fill":66,"stroke":67,"style":68},"40","46","680","204","10","#fffdf7","#59645f","stroke-width:2",[51,70,73],{"x":53,"y":71,"style":72,"fill":67,"textAnchor":57},"68","text-anchor:middle;font-size:11px;font-weight:bold;font-family:sans-serif","global — user settings, every project on this machine",[44,75],{"x":76,"y":77,"width":78,"height":79,"rx":65,"fill":80,"stroke":81,"style":68},"76","78","608","160","#eff3ff","#2563eb",[51,83,85],{"x":53,"y":84,"style":72,"fill":81,"textAnchor":57},"100","application — this QGIS session",[44,87],{"x":88,"y":89,"width":90,"height":91,"rx":65,"fill":92,"stroke":93,"style":94},"112","110","536","118","#eef7f4","#0f766e","stroke-width:2.5",[51,96,98],{"x":53,"y":97,"style":72,"fill":93,"textAnchor":57},"132","project — saved in the file, travels with it",[44,100],{"x":101,"y":102,"width":103,"height":77,"rx":65,"fill":104,"stroke":105,"style":68},"150","142","460","#fdf2e2","#b45309",[51,107,109],{"x":53,"y":108,"style":72,"fill":105,"textAnchor":57},"164","layer — set per layer",[44,111],{"x":112,"y":113,"width":114,"height":115,"rx":116,"fill":117,"stroke":118,"style":68},"196","174","368","38","8","#edf8e9","#15803d",[51,120,122],{"x":53,"y":121,"style":72,"fill":118,"textAnchor":57},"198","feature — the row being drawn",[51,124,127],{"x":53,"y":125,"style":126,"fill":67,"textAnchor":57},"262","text-anchor:middle;font-size:11px;font-family:sans-serif","Put machine-specific paths in global scope; put project facts in project scope",[129,130,132],"h2",{"id":131},"prerequisites","Prerequisites",[134,135,136,144,152],"ul",{},[137,138,139,143],"li",{},[140,141,142],"strong",{},"QGIS 3.34 LTR"," (bundled Python 3.12) or newer.",[137,145,146,147,151],{},"An open project — the singleton works, and so does a standalone ",[148,149,150],"code",{},"QgsProject()",".",[137,153,154,155,159,160,163],{},"Some familiarity with QGIS expressions; see ",[21,156,158],{"href":157},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-expressions\u002F","Working with QGIS Expressions"," if ",[148,161,162],{},"@variable"," syntax is new.",[129,165,167],{"id":166},"set-and-read-project-variables","Set and read project variables",[169,170,175],"pre",{"className":171,"code":172,"language":173,"meta":174,"style":174},"language-python shiki shiki-themes github-dark","from qgis.core import QgsProject, QgsExpressionContextUtils\n\nproject = QgsProject.instance()\n\nQgsExpressionContextUtils.setProjectVariable(project, \"survey_round\", \"2026-Q3\")\nQgsExpressionContextUtils.setProjectVariable(project, \"client_name\", \"Riverside Council\")\nQgsExpressionContextUtils.setProjectVariable(project, \"map_scale_note\", \"1:25 000 at A3\")\n\nscope = QgsExpressionContextUtils.projectScope(project)\nprint(scope.variable(\"survey_round\"))\nprint([name for name in scope.variableNames() if not name.startswith(\"qgis_\")\n       and not name.startswith(\"project_\")])\n","python","",[148,176,177,196,203,215,220,239,254,269,274,285,300,334],{"__ignoreMap":174},[178,179,182,186,190,193],"span",{"class":180,"line":181},"line",1,[178,183,185],{"class":184},"snl16","from",[178,187,189],{"class":188},"s95oV"," qgis.core ",[178,191,192],{"class":184},"import",[178,194,195],{"class":188}," QgsProject, QgsExpressionContextUtils\n",[178,197,199],{"class":180,"line":198},2,[178,200,202],{"emptyLinePlaceholder":201},true,"\n",[178,204,206,209,212],{"class":180,"line":205},3,[178,207,208],{"class":188},"project ",[178,210,211],{"class":184},"=",[178,213,214],{"class":188}," QgsProject.instance()\n",[178,216,218],{"class":180,"line":217},4,[178,219,202],{"emptyLinePlaceholder":201},[178,221,223,226,230,233,236],{"class":180,"line":222},5,[178,224,225],{"class":188},"QgsExpressionContextUtils.setProjectVariable(project, ",[178,227,229],{"class":228},"sU2Wk","\"survey_round\"",[178,231,232],{"class":188},", ",[178,234,235],{"class":228},"\"2026-Q3\"",[178,237,238],{"class":188},")\n",[178,240,242,244,247,249,252],{"class":180,"line":241},6,[178,243,225],{"class":188},[178,245,246],{"class":228},"\"client_name\"",[178,248,232],{"class":188},[178,250,251],{"class":228},"\"Riverside Council\"",[178,253,238],{"class":188},[178,255,257,259,262,264,267],{"class":180,"line":256},7,[178,258,225],{"class":188},[178,260,261],{"class":228},"\"map_scale_note\"",[178,263,232],{"class":188},[178,265,266],{"class":228},"\"1:25 000 at A3\"",[178,268,238],{"class":188},[178,270,272],{"class":180,"line":271},8,[178,273,202],{"emptyLinePlaceholder":201},[178,275,277,280,282],{"class":180,"line":276},9,[178,278,279],{"class":188},"scope ",[178,281,211],{"class":184},[178,283,284],{"class":188}," QgsExpressionContextUtils.projectScope(project)\n",[178,286,288,292,295,297],{"class":180,"line":287},10,[178,289,291],{"class":290},"sDLfK","print",[178,293,294],{"class":188},"(scope.variable(",[178,296,229],{"class":228},[178,298,299],{"class":188},"))\n",[178,301,303,305,308,311,314,317,320,323,326,329,332],{"class":180,"line":302},11,[178,304,291],{"class":290},[178,306,307],{"class":188},"([name ",[178,309,310],{"class":184},"for",[178,312,313],{"class":188}," name ",[178,315,316],{"class":184},"in",[178,318,319],{"class":188}," scope.variableNames() ",[178,321,322],{"class":184},"if",[178,324,325],{"class":184}," not",[178,327,328],{"class":188}," name.startswith(",[178,330,331],{"class":228},"\"qgis_\"",[178,333,238],{"class":188},[178,335,337,340,342,344,347],{"class":180,"line":336},12,[178,338,339],{"class":184},"       and",[178,341,325],{"class":184},[178,343,328],{"class":188},[178,345,346],{"class":228},"\"project_\"",[178,348,349],{"class":188},")])\n",[14,351,352,355,356,359,360,232,363,232,366,369,370,373,374,377],{},[140,353,354],{},"Breakdown:"," Each call writes one variable into the project, so the values are saved with the file and reload with it. ",[148,357,358],{},"projectScope()"," returns a scope object holding both your variables and the ones QGIS provides — ",[148,361,362],{},"project_title",[148,364,365],{},"project_path",[148,367,368],{},"project_home"," and friends — which is why the filter is there when you only want your own. Values are stored as strings even when set from a number; anything numeric that will be compared in an expression is worth wrapping in ",[148,371,372],{},"to_int()"," or ",[148,375,376],{},"to_real()"," at the point of use rather than hoping the implicit conversion does what you meant.",[14,379,380],{},"Setting several at once replaces the whole set, which is the right tool when a script owns the project's variables completely:",[169,382,384],{"className":171,"code":383,"language":173,"meta":174,"style":174},"QgsExpressionContextUtils.setProjectVariables(project, {\n    \"survey_round\": \"2026-Q3\",\n    \"client_name\": \"Riverside Council\",\n})\n",[148,385,386,391,404,415],{"__ignoreMap":174},[178,387,388],{"class":180,"line":181},[178,389,390],{"class":188},"QgsExpressionContextUtils.setProjectVariables(project, {\n",[178,392,393,396,399,401],{"class":180,"line":198},[178,394,395],{"class":228},"    \"survey_round\"",[178,397,398],{"class":188},": ",[178,400,235],{"class":228},[178,402,403],{"class":188},",\n",[178,405,406,409,411,413],{"class":180,"line":205},[178,407,408],{"class":228},"    \"client_name\"",[178,410,398],{"class":188},[178,412,251],{"class":228},[178,414,403],{"class":188},[178,416,417],{"class":180,"line":217},[178,418,419],{"class":188},"})\n",[14,421,422,424,425,429],{},[140,423,354],{}," The plural form takes a dictionary and ",[426,427,428],"em",{},"replaces"," every custom project variable, removing any not present in the dictionary. That is exactly right for a generated project and exactly wrong for a plugin adding one variable to a user's project — use the singular form there, or you will silently delete somebody else's work.",[129,431,433],{"id":432},"use-a-variable-in-an-expression","Use a variable in an expression",[14,435,436],{},"Variables earn their place when something else reads them. The simplest demonstration is evaluating an expression from Python with the project scope attached.",[169,438,440],{"className":171,"code":439,"language":173,"meta":174,"style":174},"from qgis.core import QgsExpression, QgsExpressionContext, QgsExpressionContextUtils\n\ncontext = QgsExpressionContext()\ncontext.appendScope(QgsExpressionContextUtils.globalScope())\ncontext.appendScope(QgsExpressionContextUtils.projectScope(project))\n\nexpression = QgsExpression(\"'Flood risk — ' || @client_name || ' — ' || @survey_round\")\nprint(expression.evaluate(context))\n",[148,441,442,453,457,467,472,477,481,496],{"__ignoreMap":174},[178,443,444,446,448,450],{"class":180,"line":181},[178,445,185],{"class":184},[178,447,189],{"class":188},[178,449,192],{"class":184},[178,451,452],{"class":188}," QgsExpression, QgsExpressionContext, QgsExpressionContextUtils\n",[178,454,455],{"class":180,"line":198},[178,456,202],{"emptyLinePlaceholder":201},[178,458,459,462,464],{"class":180,"line":205},[178,460,461],{"class":188},"context ",[178,463,211],{"class":184},[178,465,466],{"class":188}," QgsExpressionContext()\n",[178,468,469],{"class":180,"line":217},[178,470,471],{"class":188},"context.appendScope(QgsExpressionContextUtils.globalScope())\n",[178,473,474],{"class":180,"line":222},[178,475,476],{"class":188},"context.appendScope(QgsExpressionContextUtils.projectScope(project))\n",[178,478,479],{"class":180,"line":241},[178,480,202],{"emptyLinePlaceholder":201},[178,482,483,486,488,491,494],{"class":180,"line":256},[178,484,485],{"class":188},"expression ",[178,487,211],{"class":184},[178,489,490],{"class":188}," QgsExpression(",[178,492,493],{"class":228},"\"'Flood risk — ' || @client_name || ' — ' || @survey_round\"",[178,495,238],{"class":188},[178,497,498,500],{"class":180,"line":271},[178,499,291],{"class":290},[178,501,502],{"class":188},"(expression.evaluate(context))\n",[14,504,505,507,508,511,512,515,516,519,520,523,524,151],{},[140,506,354],{}," An expression evaluates against a ",[426,509,510],{},"context",", which is an ordered stack of scopes; append them from outermost to innermost so the innermost definition wins. Forgetting to append the project scope is the usual reason ",[148,513,514],{},"@client_name"," evaluates to ",[148,517,518],{},"NULL"," in a script while working perfectly in the QGIS interface, where the context is assembled for you. Checking ",[148,521,522],{},"expression.hasEvalError()"," after evaluating is worth the line — a typo in a variable name is not an exception, it is a ",[148,525,518],{},[14,527,528,529,532],{},"The same variables are now available in a layout label as ",[148,530,531],{},"[% @client_name %]",", in a label expression, in a data-defined override and in a layer's subset filter. One place to change, four places updated, which is the whole point.",[14,534,535],{},[29,536,539,542,545,548,564,567,573,578,581,584,588,591,594,598,601,605,609,612,616,620,624,629,633],{"viewBox":537,"role":32,"ariaLabel":538,"xmlns":34},"0 0 760 240","Sequence showing a Python assignment writing a variable into the project file, the project being saved and reopened, and the layout title reading the variable at render time",[36,540,541],{},"From one assignment to every rendered page",[40,543,544],{},"A Python call sets a project variable. The value is written into the project file when it is saved. On reopening, the layout title expression reads the variable through the expression context and prints the current value on every exported page, without the layout itself ever being edited.",[44,546],{"x":46,"y":46,"width":47,"height":547,"fill":49},"240",[549,550,551],"defs",{},[552,553,559],"marker",{"id":554,"viewBox":555,"refX":116,"refY":556,"markerWidth":557,"markerHeight":557,"orient":558},"varFlowArrow","0 0 10 10","5","7","auto-start-reverse",[560,561],"path",{"d":562,"fill":563},"M0 0 L10 5 L0 10 z","#2f3b35",[51,565,566],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"The layout never changes; only the value does",[44,568],{"x":569,"y":570,"width":571,"height":572,"rx":116,"fill":92,"stroke":93,"style":94},"20","60","176","72",[51,574,577],{"x":575,"y":576,"style":72,"fill":93,"textAnchor":57},"108","88","Python sets it",[51,579,580],{"x":575,"y":89,"style":126,"fill":563,"textAnchor":57},"survey round = 2026-Q3",[44,582],{"x":583,"y":570,"width":571,"height":572,"rx":116,"fill":66,"stroke":67,"style":68},"228",[51,585,587],{"x":586,"y":576,"style":72,"fill":56,"textAnchor":57},"316","saved in the project",[51,589,590],{"x":586,"y":89,"style":126,"fill":563,"textAnchor":57},"travels with the file",[44,592],{"x":593,"y":570,"width":571,"height":572,"rx":116,"fill":80,"stroke":81,"style":68},"436",[51,595,597],{"x":596,"y":576,"style":72,"fill":81,"textAnchor":57},"524","layout reads it",[51,599,600],{"x":596,"y":89,"style":126,"fill":563,"textAnchor":57},"at render time",[44,602],{"x":603,"y":570,"width":604,"height":572,"rx":116,"fill":117,"stroke":118,"style":94},"644","96",[51,606,608],{"x":607,"y":576,"style":72,"fill":118,"textAnchor":57},"692","PDF",[51,610,611],{"x":607,"y":89,"style":126,"fill":563,"textAnchor":57},"correct title",[180,613],{"x1":112,"y1":604,"x2":614,"y2":604,"stroke":563,"style":615},"222","stroke-width:2;marker-end:url(#varFlowArrow)",[180,617],{"x1":618,"y1":604,"x2":619,"y2":604,"stroke":563,"style":615},"404","430",[180,621],{"x1":622,"y1":604,"x2":623,"y2":604,"stroke":563,"style":615},"612","638",[44,625],{"x":569,"y":626,"width":627,"height":628,"rx":116,"fill":104,"stroke":105,"style":68},"166","720","52",[51,630,632],{"x":53,"y":631,"style":72,"fill":105,"textAnchor":57},"188","Next quarter: one line of Python, then re-export",[51,634,636],{"x":53,"y":635,"style":126,"fill":563,"textAnchor":57},"208","no layout editing, no forgotten label, no stale title in the corner of page 14",[129,638,640],{"id":639},"fill-in-the-project-metadata","Fill in the project metadata",[14,642,643,644,647],{},"Variables are for values the map uses. Metadata is for facts ",[426,645,646],{},"about"," the project — who made it, what it covers, under what licence — and it is what QGIS Server, catalogues and the project properties dialog read.",[169,649,651],{"className":171,"code":650,"language":173,"meta":174,"style":174},"from qgis.core import QgsAbstractMetadataBase\n\nmetadata = project.metadata()\nmetadata.setTitle(\"Flood risk atlas 2026\")\nmetadata.setAbstract(\"Modelled 1-in-100 year flood extents by ward, refreshed quarterly.\")\nmetadata.setLanguage(\"en-GB\")\nmetadata.setKeywords({\"gmd:topicCategory\": [\"flooding\", \"planning\", \"risk\"]})\n\ncontact = QgsAbstractMetadataBase.Contact()\ncontact.name = \"GIS Team\"\ncontact.email = \"gis@example.org\"\ncontact.organization = \"Riverside Council\"\nmetadata.setContacts([contact])\n\nproject.setMetadata(metadata)\nproject.write()\n",[148,652,653,664,668,678,688,698,708,735,739,749,759,769,779,785,790,796],{"__ignoreMap":174},[178,654,655,657,659,661],{"class":180,"line":181},[178,656,185],{"class":184},[178,658,189],{"class":188},[178,660,192],{"class":184},[178,662,663],{"class":188}," QgsAbstractMetadataBase\n",[178,665,666],{"class":180,"line":198},[178,667,202],{"emptyLinePlaceholder":201},[178,669,670,673,675],{"class":180,"line":205},[178,671,672],{"class":188},"metadata ",[178,674,211],{"class":184},[178,676,677],{"class":188}," project.metadata()\n",[178,679,680,683,686],{"class":180,"line":217},[178,681,682],{"class":188},"metadata.setTitle(",[178,684,685],{"class":228},"\"Flood risk atlas 2026\"",[178,687,238],{"class":188},[178,689,690,693,696],{"class":180,"line":222},[178,691,692],{"class":188},"metadata.setAbstract(",[178,694,695],{"class":228},"\"Modelled 1-in-100 year flood extents by ward, refreshed quarterly.\"",[178,697,238],{"class":188},[178,699,700,703,706],{"class":180,"line":241},[178,701,702],{"class":188},"metadata.setLanguage(",[178,704,705],{"class":228},"\"en-GB\"",[178,707,238],{"class":188},[178,709,710,713,716,719,722,724,727,729,732],{"class":180,"line":256},[178,711,712],{"class":188},"metadata.setKeywords({",[178,714,715],{"class":228},"\"gmd:topicCategory\"",[178,717,718],{"class":188},": [",[178,720,721],{"class":228},"\"flooding\"",[178,723,232],{"class":188},[178,725,726],{"class":228},"\"planning\"",[178,728,232],{"class":188},[178,730,731],{"class":228},"\"risk\"",[178,733,734],{"class":188},"]})\n",[178,736,737],{"class":180,"line":271},[178,738,202],{"emptyLinePlaceholder":201},[178,740,741,744,746],{"class":180,"line":276},[178,742,743],{"class":188},"contact ",[178,745,211],{"class":184},[178,747,748],{"class":188}," QgsAbstractMetadataBase.Contact()\n",[178,750,751,754,756],{"class":180,"line":287},[178,752,753],{"class":188},"contact.name ",[178,755,211],{"class":184},[178,757,758],{"class":228}," \"GIS Team\"\n",[178,760,761,764,766],{"class":180,"line":302},[178,762,763],{"class":188},"contact.email ",[178,765,211],{"class":184},[178,767,768],{"class":228}," \"gis@example.org\"\n",[178,770,771,774,776],{"class":180,"line":336},[178,772,773],{"class":188},"contact.organization ",[178,775,211],{"class":184},[178,777,778],{"class":228}," \"Riverside Council\"\n",[178,780,782],{"class":180,"line":781},13,[178,783,784],{"class":188},"metadata.setContacts([contact])\n",[178,786,788],{"class":180,"line":787},14,[178,789,202],{"emptyLinePlaceholder":201},[178,791,793],{"class":180,"line":792},15,[178,794,795],{"class":188},"project.setMetadata(metadata)\n",[178,797,799],{"class":180,"line":798},16,[178,800,801],{"class":188},"project.write()\n",[14,803,804,806,807,810,811,814,815,818],{},[140,805,354],{}," ",[148,808,809],{},"metadata()"," hands back a ",[426,812,813],{},"copy",", so the object must be given back with ",[148,816,817],{},"setMetadata()"," — modifying it and forgetting that line changes nothing at all and reports no error, which makes it one of the quieter mistakes in the API. Keywords are a dictionary of vocabulary to terms rather than a flat list, because the model follows the ISO metadata structure. Contacts and links are lists of small structured objects, so a project can carry both a data owner and a technical contact. Writing after setting is what persists it.",[14,820,821],{},[29,822,825,828,831,834,841,844,848,853,861,866,869,872,874,878,883,886,890,893,897,899,902,904,907,910],{"viewBox":823,"role":32,"ariaLabel":824,"xmlns":34},"0 0 760 252","Diagram contrasting variables which the map reads at render time with metadata which people and catalogues read about the project",[36,826,827],{},"Variables are read by the map; metadata is read about it",[40,829,830],{},"Project variables feed the expression engine, so labels, layouts, filters and overrides change when a variable changes. Metadata feeds the project properties dialog, QGIS Server capabilities documents and catalogue harvesters, so it changes what people and systems know about the project without affecting how it draws.",[44,832],{"x":46,"y":46,"width":47,"height":833,"fill":49},"252",[549,835,836],{},[552,837,839],{"id":838,"viewBox":555,"refX":116,"refY":556,"markerWidth":557,"markerHeight":557,"orient":558},"mdArrow",[560,840],{"d":562,"fill":563},[51,842,843],{"x":53,"y":54,"style":55,"fill":56,"textAnchor":57},"Two records in one file, with different audiences",[44,845],{"x":569,"y":628,"width":846,"height":847,"rx":65,"fill":92,"stroke":93,"style":94},"340","180",[51,849,852],{"x":850,"y":77,"style":851,"fill":93,"textAnchor":57},"190","text-anchor:middle;font-size:12px;font-weight:bold;font-family:sans-serif","variables — read by the map",[44,854],{"x":855,"y":856,"width":857,"height":858,"rx":859,"fill":66,"stroke":67,"style":860},"44","94","292","30","6","stroke-width:1.5",[51,862,865],{"x":570,"y":863,"style":864,"fill":563},"114","font-size:11px;font-family:sans-serif","label and layout expressions",[44,867],{"x":855,"y":868,"width":857,"height":858,"rx":859,"fill":66,"stroke":67,"style":860},"130",[51,870,871],{"x":570,"y":101,"style":864,"fill":563},"data-defined overrides",[44,873],{"x":855,"y":626,"width":857,"height":858,"rx":859,"fill":66,"stroke":67,"style":860},[51,875,877],{"x":570,"y":876,"style":864,"fill":563},"186","layer subset filters",[51,879,882],{"x":850,"y":880,"style":881,"fill":93,"textAnchor":57},"220","text-anchor:middle;font-size:10px;font-family:sans-serif","change one, and the map changes",[44,884],{"x":885,"y":628,"width":846,"height":847,"rx":65,"fill":80,"stroke":81,"style":94},"400",[51,887,889],{"x":888,"y":77,"style":851,"fill":81,"textAnchor":57},"570","metadata — read about the map",[44,891],{"x":892,"y":856,"width":857,"height":858,"rx":859,"fill":66,"stroke":67,"style":860},"424",[51,894,896],{"x":895,"y":863,"style":864,"fill":563},"440","project properties dialog",[44,898],{"x":892,"y":868,"width":857,"height":858,"rx":859,"fill":66,"stroke":67,"style":860},[51,900,901],{"x":895,"y":101,"style":864,"fill":563},"QGIS Server capabilities",[44,903],{"x":892,"y":626,"width":857,"height":858,"rx":859,"fill":66,"stroke":67,"style":860},[51,905,906],{"x":895,"y":876,"style":864,"fill":563},"catalogues and colleagues",[51,908,909],{"x":888,"y":880,"style":881,"fill":81,"textAnchor":57},"change one, and the map looks identical",[180,911],{"x1":912,"y1":102,"x2":913,"y2":102,"stroke":563,"style":914},"360","394","stroke-width:1.5;stroke-dasharray:4 3;marker-end:url(#mdArrow)",[14,916,917],{},"Reading it back is symmetrical, and worth doing in a script that audits a folder of projects:",[169,919,921],{"className":171,"code":920,"language":173,"meta":174,"style":174},"from pathlib import Path\n\nfor path in sorted(Path(\"\u002Fdata\u002Fprojects\").glob(\"*.qgz\")):\n    p = QgsProject()\n    if p.read(str(path)):\n        md = p.metadata()\n        print(f\"{path.name}: {md.title() or 'UNTITLED'} — {md.author() or 'no author'}\")\n",[148,922,923,935,939,966,976,990,1000],{"__ignoreMap":174},[178,924,925,927,930,932],{"class":180,"line":181},[178,926,185],{"class":184},[178,928,929],{"class":188}," pathlib ",[178,931,192],{"class":184},[178,933,934],{"class":188}," Path\n",[178,936,937],{"class":180,"line":198},[178,938,202],{"emptyLinePlaceholder":201},[178,940,941,943,946,948,951,954,957,960,963],{"class":180,"line":205},[178,942,310],{"class":184},[178,944,945],{"class":188}," path ",[178,947,316],{"class":184},[178,949,950],{"class":290}," sorted",[178,952,953],{"class":188},"(Path(",[178,955,956],{"class":228},"\"\u002Fdata\u002Fprojects\"",[178,958,959],{"class":188},").glob(",[178,961,962],{"class":228},"\"*.qgz\"",[178,964,965],{"class":188},")):\n",[178,967,968,971,973],{"class":180,"line":217},[178,969,970],{"class":188},"    p ",[178,972,211],{"class":184},[178,974,975],{"class":188}," QgsProject()\n",[178,977,978,981,984,987],{"class":180,"line":222},[178,979,980],{"class":184},"    if",[178,982,983],{"class":188}," p.read(",[178,985,986],{"class":290},"str",[178,988,989],{"class":188},"(path)):\n",[178,991,992,995,997],{"class":180,"line":241},[178,993,994],{"class":188},"        md ",[178,996,211],{"class":184},[178,998,999],{"class":188}," p.metadata()\n",[178,1001,1002,1005,1008,1011,1014,1017,1020,1023,1025,1027,1030,1033,1036,1038,1041,1043,1046,1048,1051,1053,1055],{"class":180,"line":256},[178,1003,1004],{"class":290},"        print",[178,1006,1007],{"class":188},"(",[178,1009,1010],{"class":184},"f",[178,1012,1013],{"class":228},"\"",[178,1015,1016],{"class":290},"{",[178,1018,1019],{"class":188},"path.name",[178,1021,1022],{"class":290},"}",[178,1024,398],{"class":228},[178,1026,1016],{"class":290},[178,1028,1029],{"class":188},"md.title() ",[178,1031,1032],{"class":184},"or",[178,1034,1035],{"class":228}," 'UNTITLED'",[178,1037,1022],{"class":290},[178,1039,1040],{"class":228}," — ",[178,1042,1016],{"class":290},[178,1044,1045],{"class":188},"md.author() ",[178,1047,1032],{"class":184},[178,1049,1050],{"class":228}," 'no author'",[178,1052,1022],{"class":290},[178,1054,1013],{"class":228},[178,1056,238],{"class":188},[14,1058,1059,1061,1062,1064,1065,151],{},[140,1060,354],{}," Reading each project into its own ",[148,1063,150],{}," keeps the audit away from the user's open project. An empty title is the common finding, and a report like this one is usually enough to get a team to fill them in — an untitled project is the GIS equivalent of a spreadsheet called ",[148,1066,1067],{},"final_v3_new.xlsx",[129,1069,1071],{"id":1070},"version-stamp-a-generated-project","Version-stamp a generated project",[14,1073,1074],{},"The pattern that ties both features together: a script writes the parameters it used into the project it produces, so the map can always explain itself.",[169,1076,1078],{"className":171,"code":1077,"language":173,"meta":174,"style":174},"from datetime import date\n\nQgsExpressionContextUtils.setProjectVariables(project, {\n    \"survey_round\": round_code,\n    \"generated_on\": date.today().isoformat(),\n    \"source_dataset\": source_path.name,\n})\n\nmetadata = project.metadata()\nmetadata.setTitle(f\"Flood risk — {round_code}\")\nmetadata.setAbstract(f\"Generated from {source_path.name} on {date.today().isoformat()}.\")\nproject.setMetadata(metadata)\nproject.write(str(output_path))\n",[148,1079,1080,1092,1096,1100,1107,1115,1123,1127,1131,1139,1159,1190,1194],{"__ignoreMap":174},[178,1081,1082,1084,1087,1089],{"class":180,"line":181},[178,1083,185],{"class":184},[178,1085,1086],{"class":188}," datetime ",[178,1088,192],{"class":184},[178,1090,1091],{"class":188}," date\n",[178,1093,1094],{"class":180,"line":198},[178,1095,202],{"emptyLinePlaceholder":201},[178,1097,1098],{"class":180,"line":205},[178,1099,390],{"class":188},[178,1101,1102,1104],{"class":180,"line":217},[178,1103,395],{"class":228},[178,1105,1106],{"class":188},": round_code,\n",[178,1108,1109,1112],{"class":180,"line":222},[178,1110,1111],{"class":228},"    \"generated_on\"",[178,1113,1114],{"class":188},": date.today().isoformat(),\n",[178,1116,1117,1120],{"class":180,"line":241},[178,1118,1119],{"class":228},"    \"source_dataset\"",[178,1121,1122],{"class":188},": source_path.name,\n",[178,1124,1125],{"class":180,"line":256},[178,1126,419],{"class":188},[178,1128,1129],{"class":180,"line":271},[178,1130,202],{"emptyLinePlaceholder":201},[178,1132,1133,1135,1137],{"class":180,"line":276},[178,1134,672],{"class":188},[178,1136,211],{"class":184},[178,1138,677],{"class":188},[178,1140,1141,1143,1145,1148,1150,1153,1155,1157],{"class":180,"line":287},[178,1142,682],{"class":188},[178,1144,1010],{"class":184},[178,1146,1147],{"class":228},"\"Flood risk — ",[178,1149,1016],{"class":290},[178,1151,1152],{"class":188},"round_code",[178,1154,1022],{"class":290},[178,1156,1013],{"class":228},[178,1158,238],{"class":188},[178,1160,1161,1163,1165,1168,1170,1173,1175,1178,1180,1183,1185,1188],{"class":180,"line":302},[178,1162,692],{"class":188},[178,1164,1010],{"class":184},[178,1166,1167],{"class":228},"\"Generated from ",[178,1169,1016],{"class":290},[178,1171,1172],{"class":188},"source_path.name",[178,1174,1022],{"class":290},[178,1176,1177],{"class":228}," on ",[178,1179,1016],{"class":290},[178,1181,1182],{"class":188},"date.today().isoformat()",[178,1184,1022],{"class":290},[178,1186,1187],{"class":228},".\"",[178,1189,238],{"class":188},[178,1191,1192],{"class":180,"line":336},[178,1193,795],{"class":188},[178,1195,1196,1199,1201],{"class":180,"line":781},[178,1197,1198],{"class":188},"project.write(",[178,1200,986],{"class":290},[178,1202,1203],{"class":188},"(output_path))\n",[14,1205,1206,1208,1209,151],{},[140,1207,354],{}," The variables are what the layout prints; the metadata is what a person or a catalogue reads. Recording the source dataset and the generation date costs three lines and answers, months later, the only question anybody ever asks about an old map, which is where the numbers came from. In a scheduled job this pairs naturally with the logging discipline in ",[21,1210,1212],{"href":1211},"\u002Fpyqgis-fundamentals-environment-setup\u002Fheadless-qgis-and-server-automation\u002Fhandle-errors-and-logging-in-unattended-scripts\u002F","Handle Errors and Logging in Unattended Scripts",[129,1214,1216],{"id":1215},"qgis-version-compatibility","QGIS version compatibility",[1218,1219,1220,1236],"table",{},[1221,1222,1223],"thead",{},[1224,1225,1226,1230,1233],"tr",{},[1227,1228,1229],"th",{},"QGIS version",[1227,1231,1232],{},"Python",[1227,1234,1235],{},"Notes",[1237,1238,1239,1251,1261,1272],"tbody",{},[1224,1240,1241,1245,1248],{},[1242,1243,1244],"td",{},"3.22 LTR",[1242,1246,1247],{},"3.9",[1242,1249,1250],{},"Variables and the metadata model as described.",[1224,1252,1253,1256,1258],{},[1242,1254,1255],{},"3.28 LTR",[1242,1257,1247],{},[1242,1259,1260],{},"Identical.",[1224,1262,1263,1266,1269],{},[1242,1264,1265],{},"3.34 LTR",[1242,1267,1268],{},"3.12",[1242,1270,1271],{},"Baseline for this page.",[1224,1273,1274,1277,1279],{},[1242,1275,1276],{},"3.40 \u002F 3.44",[1242,1278,1268],{},[1242,1280,1281],{},"Identical API; the project properties dialog exposes more metadata fields, all of which are the same objects.",[14,1283,1284,1285,1288,1289,1292,1293,1296],{},"Layer metadata uses the same ",[148,1286,1287],{},"QgsAbstractMetadataBase"," structure through ",[148,1290,1291],{},"layer.metadata()"," and ",[148,1294,1295],{},"layer.setMetadata()",", so anything learned here transfers directly.",[129,1298,1300],{"id":1299},"troubleshooting","Troubleshooting",[134,1302,1303,1312,1322,1332,1343,1349],{},[137,1304,1305,1311],{},[140,1306,1307,1310],{},[148,1308,1309],{},"@my_variable"," evaluates to NULL in a script."," The project scope was not appended to the expression context. Build the context with global and project scopes before evaluating.",[137,1313,1314,1317,1318,1321],{},[140,1315,1316],{},"A variable disappeared."," Something called ",[148,1319,1320],{},"setProjectVariables()"," with a dictionary that did not include it. Use the singular setter when adding to a project you do not own.",[137,1323,1324,1327,1328,373,1330,151],{},[140,1325,1326],{},"The value comes back as a string when you expected a number."," They are all stored as strings. Convert at the point of use with ",[148,1329,372],{},[148,1331,376],{},[137,1333,1334,806,1337,1339,1340,1342],{},[140,1335,1336],{},"Metadata changes are not saved.",[148,1338,809],{}," returns a copy; you must call ",[148,1341,817],{}," with the modified object and then write the project.",[137,1344,1345,1348],{},[140,1346,1347],{},"Variables set in the console vanished on restart."," They were set on the application scope rather than the project scope, or the project was never saved.",[137,1350,1351,1354,1355,1358],{},[140,1352,1353],{},"A layout title shows the expression instead of the value."," The label is in plain-text mode. Wrap the expression in ",[148,1356,1357],{},"[% ... %]"," and enable dynamic text on the item.",[129,1360,1362],{"id":1361},"conclusion","Conclusion",[14,1364,1365],{},"Project variables turn a project into something parameterised: one assignment in Python reaches every label, layout, filter and override that references the name. Metadata turns it into something self-describing, which matters the moment the project leaves the machine that made it. Both are stored in the project file, both are two lines of code, and together they are what separates a generated project that is maintainable from one that has to be regenerated to be understood.",[129,1367,1369],{"id":1368},"frequently-asked-questions","Frequently Asked Questions",[14,1371,1372,1375,1376,1379],{},[140,1373,1374],{},"What is the difference between a project variable and a custom project entry?","\nVariables are visible to the expression engine and therefore to labels, layouts and overrides. Entries written with ",[148,1377,1378],{},"writeEntry()"," are for your own code and are not visible to expressions. Use variables when the map should react, entries when only your plugin cares.",[14,1381,1382,1385],{},[140,1383,1384],{},"Do variables work in a headless export?","\nYes. They are part of the project, and a layout exported from a script resolves them exactly as an interactive export does.",[14,1387,1388,1391,1392,1395,1396,151],{},[140,1389,1390],{},"Can a variable hold a list or a date?","\nIt holds a string. Store an ISO date as text and parse it in the expression with ",[148,1393,1394],{},"to_date()","; store a list as a delimited string and split it with ",[148,1397,1398],{},"string_to_array()",[14,1400,1401,1404],{},[140,1402,1403],{},"Where do global variables live?","\nIn user settings, per installation, and they do not travel with the project. That makes them the right place for machine-specific values such as a local data root, and the wrong place for anything about the map.",[14,1406,1407,1410],{},[140,1408,1409],{},"Does metadata affect how the map renders?","\nNo. It is descriptive only — but QGIS Server publishes it, catalogues index it, and the project properties dialog shows it, so it is the first thing a new colleague reads.",[129,1412,1414],{"id":1413},"related","Related",[134,1416,1417,1422,1426,1432,1438],{},[137,1418,1419,1421],{},[21,1420,24],{"href":23}," — the guide this recipe belongs to",[137,1423,1424],{},[21,1425,158],{"href":157},[137,1427,1428],{},[21,1429,1431],{"href":1430},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-expressions\u002Fevaluate-qgis-expression-in-pyqgis\u002F","Evaluate a QGIS Expression in PyQGIS",[137,1433,1434],{},[21,1435,1437],{"href":1436},"\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002Fsave-and-load-qgis-project-pyqgis\u002F","Save and Load a QGIS Project in PyQGIS",[137,1439,1440],{},[21,1441,1443],{"href":1442},"\u002Fspatial-data-processing-automation\u002Fautomated-map-layout-generation\u002F","Automated Map Layout Generation",[1445,1446,1447],"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);}",{"title":174,"searchDepth":198,"depth":198,"links":1449},[1450,1451,1452,1453,1454,1455,1456,1457,1458,1459],{"id":131,"depth":198,"text":132},{"id":166,"depth":198,"text":167},{"id":432,"depth":198,"text":433},{"id":639,"depth":198,"text":640},{"id":1070,"depth":198,"text":1071},{"id":1215,"depth":198,"text":1216},{"id":1299,"depth":198,"text":1300},{"id":1361,"depth":198,"text":1362},{"id":1368,"depth":198,"text":1369},{"id":1413,"depth":198,"text":1414},"Parameterise a project with expression variables, read them back in labels and layouts, and fill the metadata record that makes a project self-describing when it is catalogued or published.","md",{"slug":1463,"type":1464,"breadcrumb":1465,"datePublished":1466,"dateModified":1466},"use-project-variables-and-metadata-pyqgis","article","Variables & Metadata","2026-08-15","\u002Fpyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002Fuse-project-variables-and-metadata-pyqgis",{"title":5,"description":1460},"pyqgis-fundamentals-environment-setup\u002Fworking-with-qgis-projects\u002Fuse-project-variables-and-metadata-pyqgis\u002Findex","4fRxBg4flu6WjBSMyHtSqZjX3DpTFElyMgEgLWWHjrE",1786789584634]