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.
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
@variablesyntax 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.
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.
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 version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | Variables and the metadata model as described. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Identical 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_variableevaluates 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()orto_real(). - Metadata changes are not saved.
metadata()returns a copy; you must callsetMetadata()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.