How to Build and Save a QGIS Project File from Python

On this page

Problem statement

The pipeline produces twelve GeoPackages every month. Someone then opens QGIS, drags them in one by one, re-applies the styles, re-orders the layers, sets the CRS, groups them sensibly, and saves a .qgz. It takes half an hour, it is different every time, and the person who knows how it should look is on leave.

That assembly is deterministic and belongs in the pipeline. A QGIS project is just a document describing layers, their sources, styles, order and extent β€” and QgsProject builds it from Python as readily as the GUI does.

Where it becomes fiddly:

  • layer paths that break the moment the folder moves to a shared drive β€” including "relative" ones
  • styles that must match the ones the cartographer designed
  • layer order and grouping, which the API expresses through a tree rather than a list
  • writing a .qgz headlessly, where nothing is rendered but everything must still resolve
  • projects that open with a blank canvas because the extent was never set

Quick answer

Create a project, add layers, style them, save relative:

  1. initialise QGIS headlessly and get QgsProject.instance()
  2. set the project CRS and title
  3. load each layer from an absolute path, check isValid(), apply a style, add it to the project
  4. arrange the layer tree with groups and ordering
  5. set the extent, then write() to a .qgz
%%writefile quick_project.py
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from pathlib import Path
from qgis.core import (
    Qgis, QgsApplication, QgsProject, QgsVectorLayer, QgsCoordinateReferenceSystem,
)

QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()

try:
    project = QgsProject.instance()
    project.setTitle("Isle of Wight land use")
    project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
    project.setFilePathStorage(Qgis.FilePathType.Relative)   # the default; stated for the reader

    for name, path, table in [("Land use", "data/out/landuse.gpkg", "landuse"),
                              ("Roads",    "data/out/roads.gpkg",   "roads")]:
        uri = f"{Path(path).resolve()}|layername={table}"   # absolute in, relative out
        layer = QgsVectorLayer(uri, name, "ogr")
        if not layer.isValid():
            raise RuntimeError(f"{name}: {layer.dataProvider().error().message()}")
        project.addMapLayer(layer)

    project.write("data/out/monthly.qgz")
    print("wrote monthly.qgz with", len(project.mapLayers()), "layers")
finally:
    QgsProject.instance().clear()
    qgs.exitQgis()
Writing quick_project.py
!{sys.executable} quick_project.py 2>&1 | grep -v QThreadStorage
wrote monthly.qgz with 2 layers

The resolve() is the line that decides whether the project still opens after the folder is copied to a shared drive. Relative storage β€” QGIS's default β€” rewrites an absolute source as a path relative to the project file (./landuse.gpkg). A source that is already relative, such as data/out/landuse.gpkg taken from the script's working directory, is written into the project verbatim. The first version of this script did exactly that: the project opened from the folder the script ran in, and every layer came up invalid from anywhere else. (The QThreadStorage lines QGIS prints as it shuts down are harmless and filtered out here.)

What a project file holds

The parts of a QGIS project document. what a .qgz holdsmetadatatitle, author, abstractCRS + unitsproject-widelayer registrysources + styleslayer treegroups, order, visibilitylayouts + view settingsprint, default extentThe registry holds layers; the tree decides how they are shown spatialworkflow.io
Five parts β€” the registry holds layers, the tree decides how they are shown.

Configure, add and check layers, style, arrange, save

Building a project file from Python, in order. absolute paths in, relative paths out1initialise QGIS headlessoffscreen2clear + configure the projectCRS, title, metadata3add layers from resolved pathscheck isValid4apply .qml stylescheck the flag and the fields5arrange groups and orderthe layer tree6set the default extentor the file records no view7write the .qgzthen open a moved copyA source loaded as data/out/x.gpkg is stored verbatim and breaks elsewhere spatialworkflow.io
Seven steps; the one that decides whether the project travels is resolving each layer's path.

Configure the project before adding anything

from qgis.core import QgsProject, QgsCoordinateReferenceSystem, Qgis

project = QgsProject.instance()
project.clear()                                    # start from a known state

project.setTitle("Isle of Wight land use β€” August 2026")
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
project.setFilePathStorage(Qgis.FilePathType.Relative)
project.setDistanceUnits(Qgis.DistanceUnit.Meters)
project.setAreaUnits(Qgis.AreaUnit.SquareMeters)
project.setEllipsoid("EPSG:7001")                  # for measurements

metadata = project.metadata()
metadata.setAuthor("Spatial Workflow pipeline")
metadata.setAbstract("Generated automatically; do not edit by hand.")
project.setMetadata(metadata)

project.clear() at the start matters in a long-running process: QgsProject.instance() is a singleton, so a second build in the same script would otherwise inherit the first one's layers. setFilePathStorage(Relative) is already the default, and it is applied when the project is written, so it makes no difference whether it comes before or after the layers β€” the order that matters is covered in the next step.

Add layers and check every one

from qgis.core import QgsVectorLayer, QgsRasterLayer

from pathlib import Path

def add_vector(project, path, name, layer_name=None, provider="ogr"):
    path = Path(path).resolve()                       # relative storage needs an absolute source
    uri = f"{path}|layername={layer_name}" if layer_name else str(path)
    layer = QgsVectorLayer(uri, name, provider)
    if not layer.isValid():
        raise RuntimeError(f"{name}: invalid layer {uri!r} β€” "
                           f"{layer.dataProvider().error().message()}")
    project.addMapLayer(layer, addToLegend=False)     # we place it in the tree ourselves
    return layer

def add_raster(project, path, name):
    layer = QgsRasterLayer(str(Path(path).resolve()), name, "gdal")
    if not layer.isValid():
        raise RuntimeError(f"{name}: invalid raster {path}")
    project.addMapLayer(layer, addToLegend=False)
    return layer

landuse = add_vector(project, "data/out/landuse.gpkg", "Land use", "landuse")
roads = add_vector(project, "data/out/roads.gpkg", "Roads", "roads")
basemap = add_raster(project, "data/ref/hillshade.tif", "Hillshade")
print({l.name(): l.featureCount() if hasattr(l, "featureCount") else f"{l.width()}x{l.height()} px"
       for l in (landuse, roads, basemap)})
{'Land use': 4985, 'Roads': 1786, 'Hillshade': '820x520 px'}

addToLegend=False registers the layer without putting it in the tree, which lets you build the tree explicitly instead of accepting insertion order.

Apply styles from .qml files

The cartographer's work belongs in a .qml, produced once from the GUI and version-controlled.

from pathlib import Path

def apply_style(layer, qml: Path) -> None:
    if not qml.exists():
        raise FileNotFoundError(f"style not found: {qml}")
    message, ok = layer.loadNamedStyle(str(qml))
    if not ok:
        raise RuntimeError(f"{layer.name()}: failed to load {qml.name} β€” {message}")
    layer.triggerRepaint()

apply_style(landuse, Path("styles/landuse.qml"))
apply_style(roads, Path("styles/roads.qml"))
print(landuse.name(), "β†’", type(landuse.renderer()).__name__,
      [c.value() for c in landuse.renderer().categories()])
Land use β†’ QgsCategorizedSymbolRenderer ['residential', 'commercial', 'other']

A style that loads is not a style that draws. Here is the same .qml applied to a copy of the layer that has lost its class column, both rendered headlessly:

from qgis.core import QgsMapSettings, QgsMapRendererSequentialJob
from qgis.PyQt.QtCore import QSize

gdal.VectorTranslate("data/out/landuse_noclass.gpkg", "data/out/landuse.gpkg",
                     selectFields=["osm_id", "landuse"], layerName="landuse")
noclass = QgsVectorLayer(f"{Path('data/out/landuse_noclass.gpkg').resolve()}|layername=landuse", "no class", "ogr")
print("loadNamedStyle:", noclass.loadNamedStyle("styles/landuse.qml"))

def painted(layer, extent):
    settings = QgsMapSettings()
    settings.setLayers([layer]); settings.setExtent(extent); settings.setDestinationCrs(layer.crs())
    settings.setOutputSize(QSize(400, 300)); settings.setBackgroundColor(QColor("white"))
    job = QgsMapRendererSequentialJob(settings); job.start(); job.waitForFinished()
    image = job.renderedImage()
    return sum(image.pixelColor(x, y).name() != "#ffffff" for x in range(0, 400, 2) for y in range(0, 300, 2))

print("pixels painted, of 30,000 sampled:", painted(landuse, landuse.extent()), "with class,",
      painted(noclass, landuse.extent()), "without")
loadNamedStyle: ('', True)
pixels painted, of 30,000 sampled: 11417 with class, 0 without

The load reports success with an empty message, and the map is blank. Check the field names the renderer uses against the layer before trusting the flag.

To build a style in code instead:

from qgis.core import (QgsSymbol, QgsSingleSymbolRenderer, QgsCategorizedSymbolRenderer,
                       QgsRendererCategory, QgsGraduatedSymbolRenderer, QgsClassificationQuantile)
from qgis.PyQt.QtGui import QColor

# single symbol β€” a new renderer, not renderer().setSymbol(), which only exists
# while the layer still has a single-symbol renderer
symbol = QgsSymbol.defaultSymbol(landuse.geometryType())
symbol.setColor(QColor("#0ea5e9"))
symbol.setOpacity(0.7)
landuse.setRenderer(QgsSingleSymbolRenderer(symbol))

# categorised by a field
categories = []
for value, colour in [("residential", "#14b8a6"), ("commercial", "#1a3a6b"), ("other", "#94a3b8")]:
    sym = QgsSymbol.defaultSymbol(landuse.geometryType())
    sym.setColor(QColor(colour))
    categories.append(QgsRendererCategory(value, sym, value.title()))
landuse.setRenderer(QgsCategorizedSymbolRenderer("class", categories))

# graduated by a numeric field
renderer = QgsGraduatedSymbolRenderer("area_ha")
renderer.setClassificationMethod(QgsClassificationQuantile())
renderer.updateClasses(landuse, 5)
landuse.setRenderer(renderer)
print([r.label() for r in renderer.ranges()])
['0.0009 - 0.1334', '0.1334 - 0.3987', '0.3987 - 0.9775', '0.9775 - 3.3083', '3.3083 - 1,258.2094']

The first version of the single-symbol lines called landuse.renderer().setSymbol(symbol), which works on a fresh layer and fails with AttributeError: 'QgsCategorizedSymbolRenderer' object has no attribute 'setSymbol' once a categorised style has been loaded β€” the renderer's methods depend on which renderer it is.

Saving a style back out gives you a .qml to commit:

landuse.saveNamedStyle("styles/landuse_by_area.qml")
landuse.loadNamedStyle("styles/landuse.qml")          # back to the cartographer's style
Pixels painted by the land-use style with and without the class field it is keyed on. the same .qml, loadNamedStyle ok both timeslayer with class11,417layer without class0015k px30k pxOf 30,000 sampled pixels; a successful load is not a drawn map spatialworkflow.io
Both loads returned ok=True with an empty message.

Arrange the layer tree

The tree is what the user sees: order, groups, checkboxes, expansion state.

from qgis.core import QgsLayerTreeGroup

root = project.layerTreeRoot()

background = root.insertGroup(0, "Background")
thematic = root.insertGroup(0, "Thematic")        # inserted above, so drawn on top

thematic.addLayer(landuse)
thematic.addLayer(roads)
background.addLayer(basemap)

background.setExpanded(False)
background.setItemVisibilityChecked(True)

node = root.findLayer(roads.id())
node.setItemVisibilityChecked(False)              # off by default
node.setExpanded(False)

for group in root.children():
    print(group.name(), [(n.name(), n.itemVisibilityChecked()) for n in group.children()])
Thematic [('Land use', True), ('Roads', False)]
Background [('Hillshade', True)]

Layer order in the tree is top-to-bottom as drawn, so the first child of the first group renders on top. Independent draw order is available through QgsLayerTreeMapCanvasBridge and the project's custom layer order when you need the legend and the drawing order to differ.

Set the extent so the project opens somewhere useful

from qgis.core import QgsRectangle, QgsCoordinateTransform, QgsReferencedRectangle

extent = QgsRectangle()
extent.setNull()                                    # setMinimal() is deprecated since 3.34
for layer in (landuse, roads):
    layer_extent = layer.extent()
    if layer.crs() != project.crs():
        transform = QgsCoordinateTransform(layer.crs(), project.crs(), project)
        layer_extent = transform.transformBoundingBox(layer_extent)
    extent.combineExtentWith(layer_extent)

extent.scale(1.05)                                  # a little margin
project.viewSettings().setDefaultViewExtent(
    QgsReferencedRectangle(extent, project.crs()))
print(extent.toString(0))
429213,74728 : 466639,97168

A headless build has no map canvas, so the project it writes carries no <mapcanvas> element β€” and, without this call, no <DefaultViewExtent> either. Comparing the XML of two projects written with and without it, that element is the only difference: without it the file records no view at all, and QGIS is left to choose one when the project opens.

Write the project

from pathlib import Path

dest = Path("data/out/monthly.qgz")
dest.parent.mkdir(parents=True, exist_ok=True)

if not project.write(str(dest)):
    raise RuntimeError(f"failed to write {dest}")
print(f"{dest} ({dest.stat().st_size/1024:.1f} KB)")
data/out/monthly.qgz (9.9 KB)

.qgz is a zipped .qgs and is the sensible default: smaller, single-file, and it can embed auxiliary storage. Use .qgs when the project must be diffable in version control, since it is plain XML.

Building an Isle of Wight project from config

The data used on this page

The Isle of Wight from OpenStreetMap (Geofabrik's extract, ODbL): land-use polygons with a class column and their area in hectares, and the main road network, both in British National Grid. The hillshade is made from the Copernicus GLO-90 elevation tile that covers the island (Β© DLR/Airbus, free licence). Two styles stand in for the cartographer's .qml files β€” they are built in code once and saved, the way a GUI user would save them. QGIS is started once for the notebook, as the Python console would have it.

import shutil
import sys
from pathlib import Path

import requests
from osgeo import gdal, ogr
gdal.UseExceptions()

for d in ("data/out", "data/ref", "styles", "configs"):
    Path(d).mkdir(parents=True, exist_ok=True)

PBF = Path("isle-of-wight-260101.osm.pbf")
if not PBF.exists():
    PBF.write_bytes(requests.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/isle-of-wight-260101.osm.pbf",
        timeout=300).content)

gdal.VectorTranslate("data/out/landuse.gpkg", str(PBF), layerName="landuse", dstSRS="EPSG:27700",
    geometryType="MULTIPOLYGON", SQLDialect="SQLITE", SQLStatement="""
        SELECT osm_id, name, landuse,
               CASE WHEN landuse = 'residential' THEN 'residential'
                    WHEN landuse IN ('commercial', 'retail', 'industrial') THEN 'commercial'
                    ELSE 'other' END AS class, geometry
        FROM multipolygons WHERE landuse IS NOT NULL""")
ds = ogr.Open("data/out/landuse.gpkg", 1)
lyr = ds.GetLayer("landuse")
lyr.CreateField(ogr.FieldDefn("area_ha", ogr.OFTReal))
for f in lyr:
    f.SetField("area_ha", f.GetGeometryRef().GetArea() / 1e4)
    lyr.SetFeature(f)
ds = None

gdal.VectorTranslate("data/out/roads.gpkg", str(PBF), layerName="roads", dstSRS="EPSG:27700",
    SQLDialect="SQLITE", SQLStatement="""
        SELECT osm_id, name, highway, geometry FROM lines
        WHERE highway IN ('motorway','trunk','primary','secondary','tertiary')""")

DEM = "/vsicurl/https://copernicus-dem-90m.s3.amazonaws.com/Copernicus_DSM_COG_30_N50_00_W002_00_DEM/Copernicus_DSM_COG_30_N50_00_W002_00_DEM.tif"
gdal.Warp("data/ref/dem.tif", DEM, dstSRS="EPSG:27700", outputBounds=(425000, 72000, 466000, 98000),
          xRes=50, yRes=50, resampleAlg="bilinear")
gdal.DEMProcessing("data/ref/hillshade.tif", "data/ref/dem.tif", "hillshade")

# last month's and this month's copies, for the repointing example
for month in ("2026-07", "2026-08"):
    shutil.copy("data/out/landuse.gpkg", f"data/out/landuse_{month}.gpkg")

from qgis.core import Qgis, QgsApplication
qgs = QgsApplication([], False)
qgs.initQgis()

# the cartographer's styles
from qgis.core import (QgsVectorLayer, QgsSymbol, QgsCategorizedSymbolRenderer,
                       QgsRendererCategory, QgsSingleSymbolRenderer)
from qgis.PyQt.QtGui import QColor
lu = QgsVectorLayer("data/out/landuse.gpkg|layername=landuse", "lu", "ogr")
cats = []
for value, colour in [("residential", "#14b8a6"), ("commercial", "#1a3a6b"), ("other", "#94a3b8")]:
    sym = QgsSymbol.defaultSymbol(lu.geometryType()); sym.setColor(QColor(colour))
    cats.append(QgsRendererCategory(value, sym, value.title()))
lu.setRenderer(QgsCategorizedSymbolRenderer("class", cats))
lu.saveNamedStyle("styles/landuse.qml")
rd = QgsVectorLayer("data/out/roads.gpkg|layername=roads", "rd", "ogr")
sym = QgsSymbol.defaultSymbol(rd.geometryType()); sym.setColor(QColor("#ef4444")); sym.setWidth(0.6)
rd.setRenderer(QgsSingleSymbolRenderer(sym))
rd.saveNamedStyle("styles/roads.qml")

print("QGIS", Qgis.QGIS_VERSION, "|", lu.featureCount(), "land-use polygons,", rd.featureCount(), "roads |",
      sorted(p.name for p in Path("styles").iterdir()))
QGIS 4.2.2-BelΓ©m do ParΓ‘ | 4985 land-use polygons, 1786 roads | ['landuse.qml', 'roads.qml']

Example 1: a project builder driven by config

%%writefile build_project.py
#!/usr/bin/env python3
"""build_project.py β€” assemble a .qgz from a YAML description."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from pathlib import Path
import sys, yaml
from qgis.core import (QgsApplication, QgsProject, QgsVectorLayer, QgsRasterLayer,
                       QgsCoordinateReferenceSystem, QgsCoordinateTransform,
                       QgsRectangle, QgsReferencedRectangle, Qgis)

def build(spec: dict, dest: Path) -> Path:
    project = QgsProject.instance()
    project.clear()
    project.setTitle(spec.get("title", dest.stem))
    project.setCrs(QgsCoordinateReferenceSystem(spec["crs"]))
    project.setFilePathStorage(Qgis.FilePathType.Relative)

    root = project.layerTreeRoot()
    extent = QgsRectangle(); extent.setNull()

    for group_spec in reversed(spec["groups"]):        # first group ends up on top
        group = root.insertGroup(0, group_spec["name"])
        group.setExpanded(group_spec.get("expanded", True))

        for lyr in group_spec["layers"]:
            path = str(Path(lyr["path"]).resolve())     # absolute in, relative out
            if lyr.get("type", "vector") == "vector":
                uri = f"{path}|layername={lyr['layer']}" if lyr.get("layer") else path
                layer = QgsVectorLayer(uri, lyr["name"], "ogr")
            else:
                layer = QgsRasterLayer(path, lyr["name"], "gdal")

            if not layer.isValid():
                raise RuntimeError(f"{lyr['name']}: invalid source {path}")

            if lyr.get("style"):
                message, ok = layer.loadNamedStyle(lyr["style"])
                if not ok:
                    raise RuntimeError(f"{lyr['name']}: style failed β€” {message}")
            if lyr.get("opacity") is not None and hasattr(layer, "setOpacity"):
                layer.setOpacity(float(lyr["opacity"]))

            project.addMapLayer(layer, addToLegend=False)
            node = group.addLayer(layer)
            node.setItemVisibilityChecked(lyr.get("visible", True))

            if lyr.get("extent", True):
                e = layer.extent()
                if layer.crs() != project.crs():
                    e = QgsCoordinateTransform(layer.crs(), project.crs(),
                                               project).transformBoundingBox(e)
                extent.combineExtentWith(e)
            print(f"  + {lyr['name']:<24} {lyr['path']}")

    if not extent.isEmpty():
        extent.scale(1.05)
        project.viewSettings().setDefaultViewExtent(
            QgsReferencedRectangle(extent, project.crs()))

    dest.parent.mkdir(parents=True, exist_ok=True)
    if not project.write(str(dest)):
        raise RuntimeError(f"could not write {dest}")
    return dest

def main() -> int:
    QgsApplication.setPrefixPath("/usr", True)
    qgs = QgsApplication([], False)
    qgs.initQgis()
    try:
        spec = yaml.safe_load(Path(sys.argv[1]).read_text())
        dest = build(spec, Path(sys.argv[2]))
        print(f"wrote {dest} ({dest.stat().st_size/1024:.1f} KB)")
        return 0
    except Exception as exc:
        print(f"failed: {exc}", file=sys.stderr)
        return 1
    finally:
        QgsProject.instance().clear()
        qgs.exitQgis()

if __name__ == "__main__":
    raise SystemExit(main())
Writing build_project.py
%%writefile configs/monthly_project.yml
# configs/monthly_project.yml
title: Isle of Wight land use β€” August 2026
crs: EPSG:27700
groups:
  - name: Thematic
    layers:
      - { name: Land use, path: data/out/landuse_2026-07.gpkg, layer: landuse, style: styles/landuse.qml }
      - { name: Roads,   path: data/out/roads.gpkg,   layer: roads,   style: styles/roads.qml, visible: false }
  - name: Background
    expanded: false
    layers:
      - { name: Hillshade, path: data/ref/hillshade.tif, type: raster, opacity: 0.6 }
Writing configs/monthly_project.yml
!{sys.executable} build_project.py configs/monthly_project.yml data/out/monthly_from_config.qgz
  + Hillshade                data/ref/hillshade.tif
  + Land use                 data/out/landuse_2026-07.gpkg
  + Roads                    data/out/roads.gpkg
wrote data/out/monthly_from_config.qgz (9.8 KB)

Example 2: update an existing project instead of rebuilding it

from qgis.core import QgsProject, QgsVectorLayer

project = QgsProject.instance()
project.read("data/out/monthly_from_config.qgz")

for layer in project.mapLayers().values():
    source = layer.source()
    if "2026-07" in source:
        before = (layer.id(), type(layer.renderer()).__name__)
        layer.setDataSource(source.replace("2026-07", "2026-08"),
                            layer.name(), layer.providerType())
        after = (layer.id(), type(layer.renderer()).__name__)
        print(f"repointed {layer.name()} β†’ {Path(layer.source().split('|')[0]).name}")
        print(f"  id and renderer kept: {before == after} ({after[1]})")
        print(f"  still in group: {project.layerTreeRoot().findLayer(layer.id()).parent().name()}")

project.write()          # save back to the same path
repointed Land use β†’ landuse_2026-08.gpkg
  id and renderer kept: True (QgsCategorizedSymbolRenderer)
  still in group: Thematic

setDataSource keeps the style, the layer id and its place in the tree, which is exactly what you want when only the month changed.

Example 3: add a print layout to the project

from qgis.core import (QgsPrintLayout, QgsLayoutItemMap, QgsLayoutItemLabel,
                       QgsLayoutPoint, QgsLayoutSize, QgsUnitTypes, QgsLayoutExporter)

layout = QgsPrintLayout(project)
layout.initializeDefaults()
layout.setName("A4 Overview")

landuse = project.mapLayersByName("Land use")[0]      # the project read above replaced every layer

map_item = QgsLayoutItemMap(layout)
map_item.setRect(0, 0, 200, 180)
map_item.setExtent(landuse.extent())
layout.addLayoutItem(map_item)
map_item.attemptMove(QgsLayoutPoint(10, 25, QgsUnitTypes.LayoutMillimeters))
map_item.attemptResize(QgsLayoutSize(190, 240, QgsUnitTypes.LayoutMillimeters))

title = QgsLayoutItemLabel(layout)
title.setText("Land use β€” August 2026")
title.adjustSizeToText()
layout.addLayoutItem(title)
title.attemptMove(QgsLayoutPoint(10, 10, QgsUnitTypes.LayoutMillimeters))

project.layoutManager().addLayout(layout)
project.write("data/out/monthly.qgz")

exporter = QgsLayoutExporter(layout)
result = exporter.exportToPdf("data/out/monthly.pdf", QgsLayoutExporter.PdfExportSettings())
print(result == QgsLayoutExporter.Success, f"{Path('data/out/monthly.pdf').stat().st_size / 1024:.0f} KB",
      [l.name() for l in project.layoutManager().layouts()])
True 1459 KB ['A4 Overview']

The mapLayersByName line is there because of the singleton. Example 2's project.read() cleared the project and loaded fresh layers, which deleted the C++ objects behind the earlier landuse variable; using it raised RuntimeError: wrapped C/C++ object of type QgsVectorLayer has been deleted. After a clear() or read(), fetch layers from the project again.

A project that carries its own layout means the PDF can be regenerated by anyone, from the GUI or from a script.

How a layer path given to QGIS is stored in a relative-path project, and whether it survives a move. what relative storage writeslayer loaded asstored in the .qgzmoved copydata/out/roads.gpkgdata/out/roads.gpkgbroken/…/data/out/roads.gpkg./roads.gpkgopens/…/data/ref/hillshade.tif../ref/hillshade.tifopensRelative storage converts absolute paths; a relative one is written as it came spatialworkflow.io
The stored paths Example 4 read back from the two projects.

Example 4: validate a project before shipping it

The test that matters is whether the project opens somewhere else, so the validator copies the folder and opens the copy from another working directory:

import os
import re
import shutil
import tempfile
import zipfile
from pathlib import Path
from qgis.core import QgsCoordinateReferenceSystem, QgsProject, QgsVectorLayer

def validate_project(path: Path) -> dict:
    xml = zipfile.ZipFile(path).read(next(n for n in zipfile.ZipFile(path).namelist()
                                          if n.endswith(".qgs"))).decode()
    stored = [d for d in re.findall(r"<datasource>([^<]*)</datasource>", xml) if d]
    problems = [f"stored absolute: {d}" for d in stored if Path(d.split("|")[0]).is_absolute()]

    project = QgsProject.instance()
    here = os.getcwd()
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp) / "moved"
        shutil.copytree(path.parent.parent, root)         # the whole data/ folder
        os.chdir(tmp)
        try:
            project.clear()
            if not project.read(str(root / path.parent.name / path.name)):
                return {"ok": False, "problems": [f"cannot read {path}"]}
            for layer in project.mapLayers().values():
                if not layer.isValid():
                    problems.append(f"broken after the move: {layer.name()}")
                if layer.crs() != project.crs():
                    problems.append(f"CRS differs from project: {layer.name()} ({layer.crs().authid()})")
            n = len(project.mapLayers())
        finally:
            project.clear()
            os.chdir(here)
    return {"ok": not problems, "layers": n, "stored": stored, "problems": problems}

# the same project built with the cwd-relative path the first version of the quick answer used
project = QgsProject.instance(); project.clear()
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
project.addMapLayer(QgsVectorLayer("data/out/roads.gpkg|layername=roads", "Roads", "ogr"))
project.write("data/out/naive.qgz")

for name in ("naive.qgz", "monthly_from_config.qgz"):
    print(name, validate_project(Path("data/out") / name))
naive.qgz {'ok': False, 'layers': 1, 'stored': ['data/out/roads.gpkg|layername=roads'], 'problems': ['broken after the move: Roads']}
monthly_from_config.qgz {'ok': True, 'layers': 3, 'stored': ['../ref/hillshade.tif', './landuse_2026-08.gpkg|layername=landuse', './roads.gpkg|layername=roads'], 'problems': []}

A validator that opens the project where it was built passes both of these. The naive project stores data/out/roads.gpkg β€” a path that only means something from the directory the script ran in β€” so it is broken everywhere else, and only opening a moved copy shows it.

A project is a document, not a database

A QGIS project is a document, not a database. It records which layers exist, where their data lives, how each is styled, how they are grouped and ordered, and how the canvas should open β€” and nothing else. The data stays in the files the layers point at, which is why the three-layer project built here is under 10 KB and why a moved folder can break it.

Ways to persist QGIS project state. four ways to save statecontainsbest for.qgzβœ“whole project, zippeddelivery.qgswhole project, XMLversion control.qlrone layer + its stylesharing a layer.qmlβœ“style onlypipelines, gitStyles belong in .qml files the pipeline loads and git tracks spatialworkflow.io
Four ways to persist project state, and what each is for.

Two structures matter, and they are easy to conflate. QgsProject.mapLayers() is the registry β€” every layer the project knows about, keyed by id. QgsProject.layerTreeRoot() is the tree β€” groups, order, visibility, expansion. A layer can be in the registry without being in the tree, which is exactly what addMapLayer(layer, addToLegend=False) gives you, and it is why building the tree explicitly is the reliable way to control what the user sees.

Path storage decides whether the project survives being moved, and it only works on what it is given. In relative mode β€” the default β€” an absolute source is stored relative to the project file, so a folder containing the .qgz and its data can be zipped and sent anywhere. A relative source is stored as it is, and it was relative to the script's working directory, not to the project. Load layers from resolved, absolute paths, and validate by opening a moved copy.

Styling is best treated as data. A .qml produced from the GUI captures the cartography β€” symbols, labels, categories, blend modes β€” as a file the pipeline loads and version control tracks. Building renderers in Python is entirely possible and is the right tool when the classification depends on the data, but for a fixed look, loading a .qml is both shorter and closer to what the person who designed the map intended.

Finally, remember the singleton. QgsProject.instance() is process-global, so a script that builds several projects must clear() between them or the second inherits the first's layers. Because the project owns its layers, the reverse also holds: clear() and read() delete them, and any Python variable still pointing at one now points at nothing. Clearing it before exitQgis() is part of the clean shutdown of a headless script.

File formats, ownership and styles keyed on missing fields

  • .qgz vs .qgs: .qgz is a zip containing the XML plus auxiliary data β€” smaller and self-contained. .qgs is plain XML, which diffs in git.
  • QgsProject.instance() is a singleton: Call clear() between builds in one process, or layers accumulate.
  • The project owns its layers: a layer added in a helper function and then garbage-collected on the Python side was still there at write(). The danger runs the other way β€” clear() and read() delete layers your variables still refer to.
  • addMapLayer(..., addToLegend=False) still registers the layer: It just does not appear in the tree until you add it to a group.
  • A style keyed on a missing field loads and draws nothing: loadNamedStyle returned ok=True, and the map painted no pixels. Validate field names.
  • Relative storage needs absolute input: a source loaded as data/out/x.gpkg is written verbatim and breaks from any other directory. Path(...).resolve() before creating the layer.
  • QLR files package a layer plus its style: Useful when a single styled layer should be shareable without a whole project.

FAQ

Should I save a .qgz or a .qgs?

.qgz for delivery: it is a single compressed file and can embed auxiliary data. .qgs when the project should be reviewable in version control, since it is plain XML that diffs.

How do I stop the project breaking when the folder moves?

Load every layer from an absolute path β€” Path(p).resolve() β€” and leave path storage on its default, relative. QGIS then stores each source relative to the project file. Validate by copying the folder and opening the copy from somewhere else.

How do I apply the styles our cartographer made?

Save them from the GUI as .qml files, commit them, and call layer.loadNamedStyle(path) in the build. Check the returned success flag, and check that the fields the renderer uses exist β€” a style keyed on a missing field loads successfully and draws nothing.

Why does my project open with a blank canvas?

A headless build writes no map-canvas state, so unless a default view extent is set the file records no view at all. Combine the layers' extents (transformed into the project CRS) and pass the result to project.viewSettings().setDefaultViewExtent().

What is the difference between the layer registry and the layer tree?

The registry is every layer the project knows about; the tree is the grouped, ordered, checkbox-bearing structure the user sees. Adding to the registry with addToLegend=False lets you build the tree deliberately.

How do I update last month's project for this month's data?

Read the project, call layer.setDataSource() with the new path on each layer, and write it back. The run above kept the layer id, its categorised renderer and its place in the Thematic group.

Can I add a print layout from Python?

Yes β€” build a QgsPrintLayout, add map and label items, and register it with project.layoutManager(). It is then exportable from the GUI or with QgsLayoutExporter headlessly.