Save and Load a QGIS Project in PyQGIS
Two methods cover ninety percent of project work — QgsProject.read() and QgsProject.write() — and both of them fail quietly if you let them. They return a boolean rather than raising, so a script that ignores the result carries on happily with an empty project, produces an empty map, and reports success. Getting this right is four lines of code, and it is the difference between a scheduled job that tells you it broke and one that emails an empty PDF every night for a month.
This recipe belongs to Working with QGIS Projects in PyQGIS. It covers loading a project from disk, saving it back or to a new path, the relative-versus-absolute path decision, and what to do when a project loads but its layers do not.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer. Everything here also works on 3.22 and 3.28 with the notes at the end.
- A project file to read, or write permission on the folder you intend to write to.
- For standalone scripts, an initialised
QgsApplication— see Running Python Scripts Outside QGIS Desktop.
Load a project and verify it
from qgis.core import QgsProject
project = QgsProject.instance()
if not project.read("/data/projects/flood_atlas.qgz"):
raise RuntimeError("could not read the project file")
print(project.fileName())
print(project.title())
print(len(project.mapLayers()), "layers registered")
broken = [layer.name() for layer in project.mapLayers().values() if not layer.isValid()]
if broken:
raise RuntimeError(f"layers failed to load: {', '.join(broken)}")
Breakdown: read() replaces whatever the project object currently holds, so there is no need to clear it first — though calling project.clear() explicitly makes the intent obvious in a long script. The return value covers only whether the file could be opened and parsed. Layers whose data sources cannot be reached do not make it return False; they load as invalid layer objects that keep their name, id and styling. Checking isValid() across the registry is therefore not optional in an unattended job, and raising on a broken layer is nearly always better than rendering a map with a hole in it.
Save the project
project.setTitle("Flood atlas — August 2026")
if not project.write("/data/projects/flood_atlas_august.qgz"):
raise RuntimeError("could not write the project file")
print(project.isDirty()) # False after a successful write
Breakdown: Passing a path performs a save-as: the file is written and fileName() now points at the new location, so a later bare write() saves there rather than to the original. Calling write() with no arguments saves back over the file that was read — correct for an interactive tool, risky for a scheduled job, because the input and the output become the same file and a half-finished run destroys the template. The extension decides the format: .qgz produces a zip containing the XML plus auxiliary storage, .qgs produces the bare XML. A successful write clears the dirty flag, which is a cheap way to assert that the save really happened.
To save a copy while leaving the user's open project untouched — the usual requirement in a plugin — write to a new path and then set the file name back:
original = project.fileName()
project.write("/data/exports/snapshot.qgz")
project.setFileName(original)
Breakdown: setFileName() only changes what the project thinks its path is; it writes nothing. Restoring the original path means the user's next Ctrl+S goes where they expect rather than into your export folder — a small courtesy that prevents a genuinely confusing bug report.
Choose relative paths before you save
Whether data sources are written as absolute or relative paths is a property of the project, applied at save time. Set it before writing, not after.
project.writeEntryBool("Paths", "/Absolute", False) # relative to the project folder
project.write("/data/projects/portable/atlas.qgs")
Breakdown: With relative paths, a folder containing the project and its data can be copied to another machine, zipped for a client or committed to a repository and will still open. With absolute paths it works only where it was made. The setting applies to sources written from this point on, so change it before the save that matters. Layers backed by a database or a web service are unaffected — their connection strings carry no local path — which is one reason those sources are worth preferring for anything shared.
Work on a project without touching the open one
In a plugin, or in any loop that processes several projects, use a standalone QgsProject instead of the singleton.
from qgis.core import QgsProject
for region in ("north", "central", "south"):
project = QgsProject() # independent instance
if not project.read(f"/data/templates/{region}.qgz"):
print(f"skipping {region}: unreadable")
continue
project.setTitle(f"{region.title()} — August 2026")
project.write(f"/data/output/{region}_august.qgz")
project.clear() # release layers promptly
Breakdown: Each iteration gets its own project object, so nothing leaks between regions and the user's open project in QGIS is untouched throughout. clear() at the end of the loop releases the layers rather than waiting for Python's garbage collector, which matters when the projects are large and the loop is long. Continuing rather than raising on a bad file is a judgement call: for a nightly job that must produce all three, raising is better, because a partial run that reports success is the worst outcome.
Reacting to a project being opened
Plugins usually need to know when a project loads so they can attach to its layers. The signals are on the project object.
def on_project_read():
project = QgsProject.instance()
print("opened:", project.fileName())
QgsProject.instance().readProject.connect(lambda doc: on_project_read())
QgsProject.instance().layerWasAdded.connect(lambda layer: print("added:", layer.name()))
QgsProject.instance().cleared.connect(lambda: print("project cleared"))
Breakdown: readProject fires after the XML has been parsed and passes the document, which is how a plugin reads its own custom entries out of the file. layerWasAdded fires once per layer, including during a project load, which makes it the right place to attach per-layer behaviour without enumerating the registry. cleared fires on New Project and immediately before a read replaces the contents — the signal to tear down anything holding references to layers that are about to disappear. Disconnect these in your plugin's unload(), or a reloaded plugin will handle every event twice; the pattern is covered in Connect Layer Signals in PyQGIS.
QGIS version compatibility
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | read(), write(), .qgz and the signals above all behave as described. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page. setPresetHomePath() available for overriding the project folder. |
| 3.40 / 3.44 | 3.12 | Identical API; project models and the Layouts panel gained features that do not affect these calls. |
The one thing to check across versions is not the API but the file format: a project saved by a newer QGIS may use elements an older one ignores, so styling can silently degrade when a 3.44 project is opened in 3.22. Where a project must be readable by an older release, produce it with that release — the compatibility notes in QGIS Python Version Compatibility Guide apply to project files just as much as to code.
Troubleshooting
read()returnsTruebut the map is empty. The file parsed and the layers did not resolve. Iterate the registry checkingisValid()and printlayer.source()for the failures — it is almost always a moved folder or a network share that is not mounted.write()returnsFalse. The target directory does not exist, or is not writable by the user running the script. QGIS does not create intermediate folders;os.makedirs(folder, exist_ok=True)first.- The saved project has absolute paths despite the setting. The entry was written after the save. Set
Paths/Absolutebefore callingwrite(). - A standalone script exits with the project unsaved. There is no prompt outside the GUI. Call
write()explicitly, and check the return value. - Layers appear twice after loading. The project was read into an instance that already had layers and
clear()was not called — or the same project was read twice into the singleton. - The project opens fine in QGIS but not in the script. The script's QGIS is missing a provider the project uses, such as
postgresin a minimal container. PrintQgsProviderRegistry.instance().providerList()to confirm.
Conclusion
Read with read(), write with write(), and check both return values. Verify layer validity separately, because a project can load perfectly while its data cannot. Decide on relative paths before saving anything that will travel, use a standalone QgsProject() for batch work so the user's open project is never disturbed, and hook readProject rather than polling if a plugin needs to react to project changes.
Frequently Asked Questions
Does write() with no arguments overwrite the original file?
Yes — it saves to whatever fileName() currently holds. In a scheduled job always pass an explicit output path so the input template can never be destroyed by a partial run.
Should I use .qgs or .qgz?.qgz for distribution: one file, smaller, and it carries auxiliary storage. .qgs when the project is reviewed or version-controlled, because plain XML produces a readable diff.
How do I open a project without a GUI?
Initialise QgsApplication with the offscreen platform and call read() exactly as shown. See Run PyQGIS in a Docker Container for a complete setup.
Can I load only some layers from a project?
Not selectively through read(). Read the whole project and remove what you do not need with removeMapLayer(), or read the source strings out of the XML and build only the layers you want.
Why is isDirty() still True after saving?
Something changed the project after the write — often a signal handler reacting to the save itself. Check isDirty() immediately after write() returns to distinguish a failed save from a later modification.