How to Build and Save a QGIS Project File from Python
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:
- absolute paths that break the moment the folder moves to a shared drive
- 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, title and relative-path mode before adding layers
- load each layer, 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
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (
Qgis, QgsApplication, QgsProject, QgsVectorLayer, QgsCoordinateReferenceSystem,
)
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
try:
project = QgsProject.instance()
project.setTitle("Monthly parcels")
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
project.setFilePathStorage(Qgis.FilePathType.Relative) # paths relative to the .qgz
for name, path in [("Parcels", "data/out/parcels.gpkg|layername=parcels"),
("Roads", "data/out/roads.gpkg|layername=roads")]:
layer = QgsVectorLayer(path, 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()
Relative path storage is the setting that decides whether the project still opens after the folder is copied to a shared drive β set it before adding any layers.
What a project file holds
Step-by-step solution
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("Monthly parcels β 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.
Add layers and check every one
from qgis.core import QgsVectorLayer, QgsRasterLayer
def add_vector(project, path, name, layer_name=None, provider="ogr"):
uri = f"{path}|layername={layer_name}" if layer_name else 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(path, name, "gdal")
if not layer.isValid():
raise RuntimeError(f"{name}: invalid raster {path}")
project.addMapLayer(layer, addToLegend=False)
return layer
parcels = add_vector(project, "data/out/parcels.gpkg", "Parcels", "parcels")
roads = add_vector(project, "data/out/roads.gpkg", "Roads", "roads")
basemap = add_raster(project, "data/ref/hillshade.tif", "Hillshade")
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(parcels, Path("styles/parcels.qml"))
apply_style(roads, Path("styles/roads.qml"))
To build a style in code instead:
from qgis.core import (QgsSymbol, QgsSimpleFillSymbolLayer, QgsCategorizedSymbolRenderer,
QgsRendererCategory, QgsGraduatedSymbolRenderer, QgsClassificationQuantile)
from qgis.PyQt.QtGui import QColor
# single symbol
symbol = QgsSymbol.defaultSymbol(parcels.geometryType())
symbol.setColor(QColor("#0ea5e9"))
symbol.setOpacity(0.7)
parcels.renderer().setSymbol(symbol)
# categorised by a field
categories = []
for value, colour in [("residential", "#14b8a6"), ("commercial", "#1a3a6b"), ("other", "#94a3b8")]:
sym = QgsSymbol.defaultSymbol(parcels.geometryType())
sym.setColor(QColor(colour))
categories.append(QgsRendererCategory(value, sym, value.title()))
parcels.setRenderer(QgsCategorizedSymbolRenderer("class", categories))
# graduated by a numeric field
renderer = QgsGraduatedSymbolRenderer("area_ha")
renderer.setClassificationMethod(QgsClassificationQuantile())
renderer.updateClasses(parcels, 5)
parcels.setRenderer(renderer)
Saving a style back out gives you a .qml to commit:
parcels.saveNamedStyle("styles/parcels.qml")
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(parcels)
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)
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
extent = QgsRectangle()
extent.setMinimal()
for layer in (parcels, 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()))
Without this the project opens at whatever extent the last save had, which in a headless build is nothing at all β the classic "blank canvas" complaint.
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)")
.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.
Code examples
Example 1: a project builder driven by config
#!/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.setMinimal()
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 = lyr["path"]
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} {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())
# configs/monthly_project.yml
title: Monthly parcels β August 2026
crs: EPSG:27700
groups:
- name: Thematic
layers:
- { name: Parcels, path: data/out/parcels.gpkg, layer: parcels, style: styles/parcels.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 }
Example 2: update an existing project instead of rebuilding it
from qgis.core import QgsProject, QgsVectorLayer
project = QgsProject.instance()
project.read("data/out/monthly.qgz")
for layer in project.mapLayers().values():
source = layer.source()
if "2026-07" in source:
layer.setDataSource(source.replace("2026-07", "2026-08"),
layer.name(), layer.providerType())
print(f"repointed {layer.name()} β {layer.source()}")
project.write() # save back to the same path
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")
map_item = QgsLayoutItemMap(layout)
map_item.setRect(0, 0, 200, 180)
map_item.setExtent(parcels.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("Parcels β 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)
exporter.exportToPdf("data/out/monthly.pdf", QgsLayoutExporter.PdfExportSettings())
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
from pathlib import Path
from qgis.core import QgsProject
def validate_project(path: Path) -> dict:
project = QgsProject.instance()
project.clear()
if not project.read(str(path)):
return {"ok": False, "problems": [f"cannot read {path}"]}
problems = []
for layer in project.mapLayers().values():
if not layer.isValid():
problems.append(f"broken layer: {layer.name()} β {layer.source()}")
if Path(layer.source().split("|")[0]).is_absolute():
problems.append(f"absolute path: {layer.name()} β {layer.source()}")
if layer.crs() != project.crs():
problems.append(f"CRS differs from project: {layer.name()} ({layer.crs().authid()})")
if not project.mapLayers():
problems.append("project has no layers")
return {"ok": not problems, "layers": len(project.mapLayers()), "problems": problems}
report = validate_project(Path("data/out/monthly.qgz"))
print(report)
Absolute paths are the failure that only shows up on someone else's machine, so checking for them at build time is worth the four lines.
Explanation
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 a project is a few hundred kilobytes and why a moved folder breaks 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 is the setting that decides whether the project survives being moved. In relative mode, sources are stored relative to the project file, so a folder containing the .qgz and its data/ subfolder can be zipped and sent anywhere. In absolute mode, everything breaks the moment it leaves your machine. Set it before adding layers, and validate afterwards that no source came out absolute.
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. And because layers are owned by the project, clearing it before exitQgis() is also part of the clean shutdown that keeps a headless PyQGIS script from crashing on exit.
Edge cases or notes
.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.- Layers must outlive the project write: Keep Python references until after
write(), or the layer may be garbage-collected mid-build. addMapLayer(..., addToLegend=False)still registers the layer: It just does not appear in the tree until you add it to a group.- Styles can carry data-defined properties: A
.qmlreferencing a field that your output lacks loads but renders nothing. Validate field names. - Relative paths are relative to the project file: Write the project into the folder that contains its data, not into a sibling folder.
- 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?
Set project.setFilePathStorage(Qgis.FilePathType.Relative) before adding layers, keep the project file alongside its data, and validate afterwards that no layer source is an absolute path.
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 β a failed style load is otherwise silent.
Why does my project open with a blank canvas?
No default view extent was set. 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. Styles, ids and tree position are preserved.
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.