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.

What is actually inside a QGIS projectA .qgz file is a zip container holding a .qgs XML document and an optional auxiliary storage database. The XML records data source references, styling, the layer tree order, print layouts, the project coordinate system and project variables. The spatial data itself stays outside the project, in files or a database, and is only referenced by path or connection string.A project stores references and decisions, never the dataproject.qgz — a zip containerproject.qgs — XMLdata source URI and provider per layersymbology, labelling, field aliaseslayer tree order, groups, visibilitylayouts, project CRS, variablesproject.qgd — auxiliary storage, if usedthe data lives hereroads.gpkg, dem.tif, a PostGIS tablea WFS endpoint, an XYZ tile serviceso moving data breaks the projectrelative paths survive moving the folderabsolute paths survive nothing elserefers toA 40 KB project can describe 40 GB of data — and can be regenerated in seconds

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.

The lifecycle of a project inside a scriptA left-to-right sequence: clear the project, read a file, modify layers and settings, then write. Underneath, a track shows the dirty flag as false after reading, true after any modification, and false again after a successful write. A branch notes that exiting while dirty simply discards the changes in a headless script, with no prompt.Nothing is written until you write itclear()start from nothingread(path)returns False on failuremodifylayers, styles, variableswrite(path)check the return valueisDirty()falsefalsetruefalse againA headless script that exits while dirty simply loses the work — there is nobody to prompt

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.

The registry owns layers; the tree arranges themOn the left, the project registry lists three layers by identifier with no order or grouping. On the right, the layer tree shows a group containing two of them in a chosen order, while the third is registered but absent from the tree and therefore invisible in the Layers panel even though it will still be saved with the project.Registered is not the same as visibleregistry — project.mapLayers()roads_9f2c1 — Roadsparcels_44ab7 — Parcelsscratch_10df3 — Scratchunordered, keyed by layer id, all savedtree — project.layerTreeRoot()group: Base mapRoads — visibleParcels — uncheckedScratch is not here — and not drawnaddMapLayer(layer, False) registers without showing — the second argument is the whole difference

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.

One variable, read in four placesA single call to set a project variable named survey round writes one value into the project file. Four consumers read it through the expression engine: a feature label, a print layout title block, a data defined symbol size, and a layer subset filter. Changing the value in one place updates all four without editing any of them.Set it once, read it everywheresetProjectVariablesurvey round = 2026-Q3label expressionround shown per featurelayout titleprinted on every pagedata-defined sizesymbology reacts to itlayer filteronly this round loadsNext quarter, one line changes and nothing else has to

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.

Three places a setting can liveThree columns compare storage scopes. Project scope holds the coordinate system, units, snapping and custom entries, and travels inside the project file. Application scope holds installation paths, proxy settings and plugin preferences, and stays on the machine. Layer scope holds styling, joins and field configuration, and follows the layer wherever it is used.Ask where a setting should travel to, then store it thereproject scopeCRS, units, ellipsoidsnapping, relationsvariables, metadatayour writeEntry keystravels with the fileapplication scopeinstall and data pathsproxy, authenticationplugin preferencesQgsSettings keysstays on the machinelayer scopesymbology and labelsfield aliases, formsjoins and subset filterlayer variablesfollows the layer

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/Absolute to False before 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.