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 .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, title and relative-path mode before adding layers
  3. load each layer, 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
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

Layered anatomy of a QGIS project: metadata, CRS, layer registry, layer tree, layouts.
Five parts β€” the registry holds layers, the tree decides how they are shown.

Step-by-step solution

Vertical steps: init QGIS, configure project, add layers, style, arrange tree, set extent, write.
Seven steps, in this order β€” CRS and path mode must come before the layers.

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.

Grid comparing .qgs, .qgz, QLR and templates on contents, size and version control.
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 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

  • .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.
  • 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 .qml referencing 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.

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.