Working with QGIS Projects in PyQGIS
Almost every PyQGIS example starts with a layer, which quietly skips the thing that holds the layers together. A QGIS project is the document your users actually open: it remembers which datasets are involved, how they are styled, what order they draw in, which coordinate system the map is displayed in, which layouts exist, and a hundred small preferences that nobody wants to set twice. Automating QGIS without touching projects means rebuilding all of that in code every time a script runs.
This guide sits inside PyQGIS Fundamentals & Environment Setup and covers the QgsProject API from both directions: reading an existing project so a script can work with what a cartographer already built, and writing one so the output of an automated pipeline is something a human can open. It is the piece that makes the unattended workflows in Headless QGIS and Server Automation practical, because the fastest way to produce a good map from a script is to start from a project somebody designed by hand.
The project object every script already has
QgsProject.instance() returns the single project the running QGIS is working with. In the Python console that is whatever the user has open; in a standalone script it is an empty project created for you when QgsApplication initialises. Everything on it is available immediately.
from qgis.core import QgsProject
project = QgsProject.instance()
print(project.fileName()) # '' until it has been saved or read
print(project.crs().authid()) # 'EPSG:4326' on a fresh project
print(len(project.mapLayers())) # layers currently registered
print(project.isDirty()) # unsaved changes present?
Breakdown: mapLayers() returns a dictionary keyed by layer id, not by name — ids are stable across renames, which is why the project stores them and why mapLayersByName() is a convenience rather than an identity mechanism. isDirty() is the flag QGIS uses to decide whether to prompt on close; a script that changes a project and then does not write it leaves that flag set, which is exactly why an automated job should either write explicitly or clear the flag before exiting. fileName() being empty is the reliable way to tell an unsaved project from a loaded one.
For a second project loaded alongside the current one — comparing two, or merging layers from one into another — construct QgsProject() directly instead of using the singleton. Nothing in the API forces you through instance(), and keeping a batch job's working project separate from the user's open project avoids a whole class of surprise.
Reading and writing
Two methods do the file work, and both return a boolean you must check.
project = QgsProject.instance()
if not project.read("/data/projects/flood_atlas.qgz"):
raise RuntimeError("project failed to load")
project.setTitle("Flood atlas — August")
project.write("/data/projects/flood_atlas_august.qgz")
Breakdown: read() replaces the entire contents of the project object, so anything already loaded is discarded — call project.clear() first if you want that to be explicit rather than implicit. Passing a path to write() performs a save-as and updates fileName(); calling write() with no arguments saves back over the file that was read, which is the form to avoid in a scheduled job unless overwriting the source is genuinely intended. Both .qgs (plain XML) and .qgz (zipped) are chosen by the extension you supply; .qgz is smaller and keeps auxiliary storage in the same file, and is the better default for anything that gets emailed or committed.
A project read from disk fires signals as it loads — layerLoaded, readProject, homePathChanged — which is how plugins hook into project opening. In a script the useful one is layerWasAdded, because it lets you attach behaviour to every layer without knowing what the project contains.
The full walkthrough, including reading a project whose layers are unavailable and writing to a template folder, is in Save and Load a QGIS Project in PyQGIS.
Two lists, not one: the registry and the layer tree
The single most useful thing to understand about projects is that a layer exists in two places, and confusing them produces the classic "my layer loaded but nothing appears in the panel" bug.
The registry is ownership: QgsProject.addMapLayer() puts a layer into the project so it stays alive, gets saved, and can be found by id. The layer tree is presentation: the ordered, groupable structure the Layers panel draws and the map canvas renders from. By default addMapLayer() does both, and the second argument is what separates them.
from qgis.core import QgsProject, QgsVectorLayer
project = QgsProject.instance()
layer = QgsVectorLayer("/data/roads.gpkg|layername=roads", "Roads", "ogr")
project.addMapLayer(layer, False) # register, but do not show
group = project.layerTreeRoot().findGroup("Base map")
group.insertLayer(0, layer) # decide exactly where it appears
Breakdown: Passing False as addToLegend registers the layer without touching the tree, and the following two lines then place it precisely — inside a named group, at the top. Doing it in one step with addMapLayer(layer) always appends at the top level, which is why scripts that build a structured project use the two-step form. The reverse operation follows the same split: removeMapLayer(layer.id()) deletes it from both, while removing only the tree node leaves an orphaned registered layer that still gets written to the file.
Groups, visibility, ordering and check states all live on the tree, and Organise the Layer Tree with Groups in PyQGIS works through the node API. Adding and removing layers safely — including the signal-ordering traps when other code is listening — is covered in Add and Remove Layers from a Project in PyQGIS.
Paths, and why projects break when they move
A project stores a data source string per layer, and whether that string is absolute or relative is a project property rather than a per-layer one. Get it wrong and the project works perfectly on the machine that made it and nowhere else.
project.writeEntryBool("Paths", "/Absolute", False) # store relative paths
print(project.homePath()) # the folder paths resolve against
project.setPresetHomePath("/data/projects/flood") # override it explicitly
Breakdown: writeEntry* and readEntry* are the generic key–value store every part of QGIS uses for project settings, addressed by a scope and a key — the same mechanism plugins use to persist their own per-project state. Setting Paths/Absolute to False makes new saves record data sources relative to the project folder, which is what you want for anything that travels: a folder containing the project and its data can be copied, zipped or checked into version control and still opens. homePath() returns the project's folder unless a preset home path overrides it, and it is also what the @project_home expression variable resolves to.
Relative paths only help when the data actually travels with the project. For layers that live in a database or a web service the question does not arise: the connection string is location-independent already, which is one more argument for the workflows in PostGIS and Database Workflows in PyQGIS and Web Services and Remote Data in PyQGIS.
When a project does open with broken layers, they are not lost — QGIS keeps the invalid layers with their original source strings, and layer.dataProvider().isValid() tells you which. Rewriting the source in place is the repair:
for layer in project.mapLayers().values():
if not layer.isValid():
old = layer.source()
layer.setDataSource(old.replace("/mnt/old_share/", "/data/"),
layer.name(), layer.providerType())
Breakdown: setDataSource() re-points an existing layer object, keeping its id, styling, labelling and every reference to it from layouts and joins — which is exactly what makes it the right tool and a string replacement on the XML the wrong one. The provider type must be passed unchanged unless you are genuinely switching provider, and the layer becomes valid immediately if the new source resolves.
Project variables and metadata
Variables are how a project carries values into expressions without hard-coding them in every label, layout and data-defined override. They are ordinary key–value pairs stored in the project file, readable and writable from Python.
from qgis.core import QgsExpressionContextUtils
QgsExpressionContextUtils.setProjectVariable(project, "survey_round", "2026-Q3")
QgsExpressionContextUtils.setProjectVariable(project, "client_name", "Riverside Council")
variables = QgsExpressionContextUtils.projectScope(project).variableNames()
print([name for name in variables if not name.startswith("qgis_")])
Breakdown: A project variable set this way is available anywhere expressions are evaluated — as @survey_round in a label, a layout title, a filter or a data-defined size — so one Python assignment updates every place the value appears. Values are stored as strings in the project file even when set from a number, which is why comparisons in expressions often need to_int() or to_real(). The scope object also exposes QGIS's own built-in variables, hence filtering the qgis_ prefix when you only want your own.
Project metadata is the separate, structured record — title, abstract, author, contact, licence, keywords, extent — that matters when projects are catalogued or published to QGIS Server.
metadata = project.metadata()
metadata.setTitle("Flood risk atlas 2026")
metadata.setAbstract("Modelled 1-in-100 year extents by ward, updated quarterly.")
metadata.setLanguage("en-GB")
project.setMetadata(metadata)
Breakdown: metadata() returns a copy, so the object must be handed back with setMetadata() — modifying it in place changes nothing, which is a common and entirely silent mistake. Filling these fields costs four lines and makes a published project self-describing; both are worked through in Use Project Variables and Metadata in PyQGIS.
The template-project pattern
The most valuable thing projects give an automation pipeline is a starting point that somebody with cartographic judgement produced. Rebuilding a good map in code — every symbol, every label rule, every layout item — is slow to write and worse to maintain. Reading a template and swapping what varies is neither.
from qgis.core import QgsProject
def render_for_region(template_path, region_code, output_path):
project = QgsProject() # not the singleton
if not project.read(template_path):
raise RuntimeError(f"cannot read {template_path}")
QgsExpressionContextUtils.setProjectVariable(project, "region_code", region_code)
boundary = project.mapLayersByName("Boundary")[0]
boundary.setSubsetString(f"region = '{region_code}'")
project.write(output_path)
return project
Breakdown: The template holds all the styling, labelling and layout work; the script changes only the two things that vary between runs, then writes a per-region project that a human can open and check. Using a fresh QgsProject() rather than the singleton means this function is safe to call in a loop and inside a running QGIS without disturbing whatever the user has open. Layers are addressed by name here for readability — in a template you control, that is reasonable; in a project you do not, prefer the stable layer id.
From there the layouts inside the project are ready to export, which is where this joins Automated Map Layout Generation and, for one map per feature, Automating Atlas Map Series. The pattern generalises well: a template project, a table of parameters, and a loop that writes one project and one PDF per row.
Project properties beyond the layers
A surprising amount of behaviour that people assume is a QGIS preference is actually stored per project, which means an automated pipeline has to set it or inherit whatever the template had. The coordinate system is the obvious one, and the least forgiving.
from qgis.core import QgsCoordinateReferenceSystem, QgsUnitTypes
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
project.setDistanceUnits(QgsUnitTypes.DistanceMeters)
project.setAreaUnits(QgsUnitTypes.AreaSquareMeters)
project.setEllipsoid("EPSG:7001")
Breakdown: The project CRS is the coordinate system the canvas and layouts display in; individual layers keep their own and are reprojected on the fly, so setting this changes what the map looks like without touching any data. The distance and area units govern what the measure tools and, importantly, expression functions such as $area and $length return — a project left on degrees will happily report a parcel's area as 0.0000031 and nobody will notice until it reaches a report. The ellipsoid is what makes those measurements ellipsoidal rather than planar; setting it to a sensible value is the difference between a length measured across the curved earth and one measured on a flattened projection, which on long features can differ by a percent or more. The interaction between these settings and layer-level transforms is worked through in Coordinate Reference Systems in PyQGIS.
Other per-project settings worth knowing about, all reachable through the same generic entry store:
- Snapping and topological editing, via
QgsProject.snappingConfig()— essential if a script prepares a project for a digitising team, and completely invisible until somebody starts editing. - The default styles and symbology applied to newly added layers, so a generated project does not look like random colours.
- Macros and Python startup code embedded in the project. QGIS asks the user before running them; a project written by an automated job should generally not contain any, because it turns a data file into executable code.
- Custom project properties, written with
writeEntry()under a scope you choose. This is where a plugin should keep per-project state such as "which layer holds the survey points" instead of guessing by name each time.
project.writeEntry("FloodAtlas", "/SurveyLayerId", boundary.id())
layer_id, ok = project.readEntry("FloodAtlas", "/SurveyLayerId", "")
Breakdown: The scope string namespaces the key so two plugins cannot collide, and the read call returns a tuple of value and success flag — checking the flag rather than the value is what distinguishes "not set" from "set to an empty string". Values are stored in the project XML and therefore travel with the file, which is exactly the property you want for anything describing that specific project and exactly the property you do not want for machine-specific paths.
Projects, version control and reproducibility
Because a project is a text document that points at data rather than containing it, it belongs in version control — and treating it that way changes how a team works. A reviewer can see that a colleague changed the classification breaks on the risk layer, because that is a visible diff. A broken map can be rolled back in a second. A release can be tagged.
Three practices make it work in practice.
Prefer .qgs for anything reviewed. The zipped .qgz is a binary blob as far as version control is concerned; every save produces a completely different file. The plain XML diffs line by line, and the extra size is irrelevant next to the data it references.
Write projects from a script where you can. A project generated by the template pattern above is reproducible by definition: the script and its inputs are the source of truth, and the project is a build artefact. That is the same argument that makes generated projects a natural fit for the scheduled workflows in Schedule PyQGIS Scripts with cron.
Normalise before comparing. QGIS writes a few volatile things into the file — window geometry, the last canvas extent, a save timestamp — so two saves of an unchanged project are not byte-identical. When a test needs to assert that a script produced the expected project, compare the parts you care about rather than the whole file:
import xml.etree.ElementTree as ET
tree = ET.parse("/data/projects/flood_atlas.qgs")
sources = sorted(el.get("source") for el in tree.iter("datasource")
if el.get("source"))
layer_names = sorted(el.findtext("layername") or "" for el in tree.iter("maplayer"))
Breakdown: Reading the XML directly is fine for assertions about a project, and much simpler than loading it into a QGIS application inside a test. It is not fine for modifying one — that is what setDataSource() and the rest of the API exist for, because the internal references between layers, layouts, relations and joins are easy to break with a text edit and produce a project that opens with no visible error and quietly wrong behaviour. The same split applies to the plugin test suites described in Unit Test a QGIS Plugin with pytest: parse to assert, use the API to change.
One consequence worth planning for: a project that is generated should never be edited by hand, and a project that is hand-edited should never be regenerated. Decide which of the two a given file is, say so in the folder, and the team will not lose an afternoon's cartography to a nightly job.
Key takeaways
- A project is references and decisions, not data. It is small, regenerable and version-controllable — and it breaks the moment the data it points at moves.
- The registry and the layer tree are separate.
addMapLayer(layer, False)registers without showing; forgetting the second argument is why scripts cannot control layer order. - Store relative paths for any project that travels, and set
Paths/AbsolutetoFalsebefore writing rather than fixing it afterwards. - Repair broken layers with
setDataSource(), which keeps the layer id, styling and every reference to it intact. - Project variables are the parameter mechanism. One assignment reaches labels, layouts, filters and data-defined overrides.
- Start from a template project. Reading a hand-built project and changing what varies beats constructing cartography in code, every time.
Frequently Asked Questions
Should I use QgsProject.instance() or create my own project?
Use the singleton when the script is meant to act on what the user has open, and a fresh QgsProject() for anything batch-like. A loop that reads twenty template projects into the singleton will fight with the running application's state; twenty independent project objects will not.
Why does my layer not appear in the Layers panel even though it loaded?
Either it was never added to the project at all — a QgsVectorLayer that is only referenced by a Python variable is invisible and will be garbage-collected — or it was added with addToLegend set to False and never inserted into the layer tree.
What is the difference between .qgs and .qgz?.qgs is the raw XML document; .qgz is a zip containing that XML plus auxiliary storage. Prefer .qgz for distribution and .qgs when you want a readable diff in version control — the format is chosen purely by the file extension you pass to write().
How do I change a data source for every layer at once?
Iterate project.mapLayers().values() and call setDataSource() on each, rewriting the part of the string that changed. Do it in a script rather than by editing the XML, because layouts, joins and relations all reference layers by id and a text edit is easy to get subtly wrong.
Can I read a project without a running QGIS application?
You need QgsApplication initialised, but not a GUI — a standalone script with the offscreen platform reads and writes projects perfectly well, which is the basis of the workflows in Headless QGIS and Server Automation.
Do project variables survive a save? Yes — they are written into the project file and reload with it. Variables set on the application scope do not; those live in user settings and are per-installation, which is the right home for a machine-specific path.
Related Guides
- Up: PyQGIS Fundamentals & Environment Setup — the parent guide for this topic
- QGIS Python Console Basics
- Headless QGIS and Server Automation
- Working with QGIS Expressions
- Save and Load a QGIS Project in PyQGIS
- Add and Remove Layers from a Project in PyQGIS
- Organise the Layer Tree with Groups in PyQGIS
- Use Project Variables and Metadata in PyQGIS