Use Project Variables and Metadata in PyQGIS

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.

This recipe belongs to 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.

Variable scopes, innermost winsFive 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.The same name can exist five times — the innermost one winsglobal — user settings, every project on this machineapplication — this QGIS sessionproject — saved in the file, travels with itlayer — set per layerfeature — the row being drawnPut machine-specific paths in global scope; put project facts in project scope

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • An open project — the singleton works, and so does a standalone QgsProject().
  • Some familiarity with QGIS expressions; see Working with QGIS Expressions if @variable syntax is new.

Set and read project variables

from qgis.core import QgsProject, QgsExpressionContextUtils

project = QgsProject.instance()

QgsExpressionContextUtils.setProjectVariable(project, "survey_round", "2026-Q3")
QgsExpressionContextUtils.setProjectVariable(project, "client_name", "Riverside Council")
QgsExpressionContextUtils.setProjectVariable(project, "map_scale_note", "1:25 000 at A3")

scope = QgsExpressionContextUtils.projectScope(project)
print(scope.variable("survey_round"))
print([name for name in scope.variableNames() if not name.startswith("qgis_")
       and not name.startswith("project_")])

Breakdown: Each call writes one variable into the project, so the values are saved with the file and reload with it. projectScope() returns a scope object holding both your variables and the ones QGIS provides — project_title, project_path, 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 to_int() or to_real() at the point of use rather than hoping the implicit conversion does what you meant.

Setting several at once replaces the whole set, which is the right tool when a script owns the project's variables completely:

QgsExpressionContextUtils.setProjectVariables(project, {
    "survey_round": "2026-Q3",
    "client_name": "Riverside Council",
})

Breakdown: The plural form takes a dictionary and 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.

Use a variable in an expression

Variables earn their place when something else reads them. The simplest demonstration is evaluating an expression from Python with the project scope attached.

from qgis.core import QgsExpression, QgsExpressionContext, QgsExpressionContextUtils

context = QgsExpressionContext()
context.appendScope(QgsExpressionContextUtils.globalScope())
context.appendScope(QgsExpressionContextUtils.projectScope(project))

expression = QgsExpression("'Flood risk — ' || @client_name || ' — ' || @survey_round")
print(expression.evaluate(context))

Breakdown: An expression evaluates against a 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 @client_name evaluates to NULL in a script while working perfectly in the QGIS interface, where the context is assembled for you. Checking expression.hasEvalError() after evaluating is worth the line — a typo in a variable name is not an exception, it is a NULL.

The same variables are now available in a layout label as [% @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.

From one assignment to every rendered pageA 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.The layout never changes; only the value doesPython sets itsurvey round = 2026-Q3saved in the projecttravels with the filelayout reads itat render timePDFcorrect titleNext quarter: one line of Python, then re-exportno layout editing, no forgotten label, no stale title in the corner of page 14

Fill in the project metadata

Variables are for values the map uses. Metadata is for facts 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.

from qgis.core import QgsAbstractMetadataBase

metadata = project.metadata()
metadata.setTitle("Flood risk atlas 2026")
metadata.setAbstract("Modelled 1-in-100 year flood extents by ward, refreshed quarterly.")
metadata.setLanguage("en-GB")
metadata.setKeywords({"gmd:topicCategory": ["flooding", "planning", "risk"]})

contact = QgsAbstractMetadataBase.Contact()
contact.name = "GIS Team"
contact.email = "gis@example.org"
contact.organization = "Riverside Council"
metadata.setContacts([contact])

project.setMetadata(metadata)
project.write()

Breakdown: metadata() hands back a copy, so the object must be given back with 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.

Variables are read by the map; metadata is read about itProject 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.Two records in one file, with different audiencesvariables — read by the maplabel and layout expressionsdata-defined overrideslayer subset filterschange one, and the map changesmetadata — read about the mapproject properties dialogQGIS Server capabilitiescatalogues and colleagueschange one, and the map looks identical

Reading it back is symmetrical, and worth doing in a script that audits a folder of projects:

from pathlib import Path

for path in sorted(Path("/data/projects").glob("*.qgz")):
    p = QgsProject()
    if p.read(str(path)):
        md = p.metadata()
        print(f"{path.name}: {md.title() or 'UNTITLED'}{md.author() or 'no author'}")

Breakdown: Reading each project into its own QgsProject() 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 final_v3_new.xlsx.

Version-stamp a generated project

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.

from datetime import date

QgsExpressionContextUtils.setProjectVariables(project, {
    "survey_round": round_code,
    "generated_on": date.today().isoformat(),
    "source_dataset": source_path.name,
})

metadata = project.metadata()
metadata.setTitle(f"Flood risk — {round_code}")
metadata.setAbstract(f"Generated from {source_path.name} on {date.today().isoformat()}.")
project.setMetadata(metadata)
project.write(str(output_path))

Breakdown: 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 Handle Errors and Logging in Unattended Scripts.

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9Variables and the metadata model as described.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical API; the project properties dialog exposes more metadata fields, all of which are the same objects.

Layer metadata uses the same QgsAbstractMetadataBase structure through layer.metadata() and layer.setMetadata(), so anything learned here transfers directly.

Troubleshooting

  • @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.
  • A variable disappeared. Something called setProjectVariables() with a dictionary that did not include it. Use the singular setter when adding to a project you do not own.
  • 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 to_int() or to_real().
  • Metadata changes are not saved. metadata() returns a copy; you must call setMetadata() with the modified object and then write the project.
  • 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.
  • A layout title shows the expression instead of the value. The label is in plain-text mode. Wrap the expression in [% ... %] and enable dynamic text on the item.

Conclusion

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.

Frequently Asked Questions

What is the difference between a project variable and a custom project entry? Variables are visible to the expression engine and therefore to labels, layouts and overrides. Entries written with 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.

Do variables work in a headless export? Yes. They are part of the project, and a layout exported from a script resolves them exactly as an interactive export does.

Can a variable hold a list or a date? It holds a string. Store an ISO date as text and parse it in the expression with to_date(); store a list as a delimited string and split it with string_to_array().

Where do global variables live? In 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.

Does metadata affect how the map renders? No. 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.