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.

What read() actually does, and where it can go wrongReading a project parses the XML, then resolves each layer against its stored data source. Three outcomes follow: the file itself is missing or unreadable and read returns false; the file parses but some data sources cannot be reached, in which case read still returns true and the layers are marked invalid; or everything resolves and the project is fully usable.read() returning True does not mean the layers arrivedparse the XMLfrom .qgs or .qgzresolve each layeropen its data sourcefile missing or corruptread() returns Falsecheck it and stopdata source unreachableread() still returns Truelayer.isValid() is Falseeverything resolveslayers, styles, layoutsall presentThe middle case is the dangerous one — it looks like success

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.

The same project, copied to another machineOn the left a project saved with absolute paths refers to a data folder under a specific user's home directory; after copying the folder to a different machine the layers cannot be found. On the right the same project saved with relative paths refers to a data folder beside the project, so the copy opens with every layer intact.One checkbox decides whether the project survives a copyabsolute pathssource: /home/ana/work/gis/data/roads.gpkgcopied to a colleague's laptopno such folder — every layer invalidrelative pathssource: ./data/roads.gpkgcopied to a colleague's laptopopens exactly as it did at home

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.

The order signals arrive when a project opensA left-to-right sequence shows the cleared signal firing first as the previous project is discarded, then layerWasAdded firing once for each layer as it is registered, then readProject firing after the document has been parsed, and finally the project being fully usable. A note marks that reading custom project entries belongs in the readProject handler.Attach to the right moment, not the first onecleareddrop referenceslayerWasAdded, once per layerattach per-layer behaviour herereadProjectread your custom entriesusableDisconnect all of these in unload() or a reloaded plugin reacts twice

QGIS version compatibility

QGIS versionPythonNotes
3.22 LTR3.9read(), write(), .qgz and the signals above all behave as described.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page. setPresetHomePath() available for overriding the project folder.
3.40 / 3.443.12Identical 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() returns True but the map is empty. The file parsed and the layers did not resolve. Iterate the registry checking isValid() and print layer.source() for the failures — it is almost always a moved folder or a network share that is not mounted.
  • write() returns False. 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/Absolute before calling write().
  • 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 postgres in a minimal container. Print QgsProviderRegistry.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.