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
.qgzheadlessly, 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:
- initialise QGIS headlessly and get
QgsProject.instance() - set the project CRS and title
- load each layer from an absolute path, check
isValid(), apply a style, add it to the project - arrange the layer tree with groups and ordering
- 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
Configure, add and check layers, style, arrange, save
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
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.
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.
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
.qgzvs.qgs:.qgzis a zip containing the XML plus auxiliary data β smaller and self-contained..qgsis plain XML, which diffs in git.QgsProject.instance()is a singleton: Callclear()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()andread()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:
loadNamedStylereturnedok=True, and the map painted no pixels. Validate field names. - Relative storage needs absolute input: a source loaded as
data/out/x.gpkgis 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.
Internal links
- How to Style Layers and Export Map Layouts from PyQGIS
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- How to Run a PyQGIS Script Headless Without Opening QGIS
- How to Add, Edit and Delete Features with PyQGIS
- PyQGIS Layer Fails to Load (isValid() Returns False): How to Fix It
- How to Load and Write PostGIS Layers from PyQGIS
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.