Fix Broken Layer Paths in a Project with PyQGIS
A project file does not contain data, only directions to it. Move a share from S:\ to \\fileserver\gis, rename a database host, or copy a project to a colleague's laptop, and every direction points somewhere that no longer exists. QGIS Desktop offers the Handle Unavailable Layers dialog, which is fine for one project and three layers. For two hundred projects on a file server after a migration, you want a script.
This recipe belongs to Layer Data Sources & Formats in PyQGIS. It opens projects without trying to load their data, lists what is broken, rewrites the sources with a mapping table, and saves the result with relative paths so the next move is harmless.
Prerequisites
- QGIS 3.40 LTR or newer; the flags shown also exist on 3.28 and later under their older names.
- A copy of the projects to repair. Write the fixed files next to the originals or into a new folder until you trust the script.
- Knowing where the data went. A script can apply a mapping; it cannot discover one.
Open a project without loading its layers
Reading a project normally makes every provider try to open its source, which for a missing network share means a timeout per layer and, on some platforms, a modal dialog. The DontResolveLayers flag reads the layer definitions and creates placeholder layers that keep their source strings but never touch the data.
from qgis.core import Qgis, QgsProject
project = QgsProject.instance()
project.clear()
ok = project.read(
"/srv/projects/flood_2025.qgz",
Qgis.ProjectReadFlag.DontResolveLayers,
)
if not ok:
raise RuntimeError(project.error())
broken = [lyr for lyr in project.mapLayers().values() if not lyr.isValid()]
print(f"{len(broken)} of {len(project.mapLayers())} layers unresolved")
Breakdown: Placeholder layers are all invalid by construction, which is fine for this purpose — the question is not whether they opened but where they point. Style, labels, joins and layout references are read as normal, so everything that should survive the repair is already attached to the layer objects. project.clear() first matters in the Python console, where the singleton otherwise still holds whatever project was open before.
To tell a genuinely broken layer from one that was merely not resolved, check the file:
import os
from qgis.core import QgsProviderRegistry
registry = QgsProviderRegistry.instance()
missing = []
for layer in project.mapLayers().values():
parts = registry.decodeUri(layer.providerType(), layer.source())
path = parts.get("path")
if path and not os.path.exists(path):
missing.append((layer, parts))
for layer, parts in missing:
print(f"{layer.name():<30} {parts['path']}")
Breakdown: Only file-based providers return a path, so database and web layers are skipped here and handled separately below. os.path.exists is quick against a local disk and slow against a dead network share; on a large batch, check each distinct directory once and cache the answer rather than stat-ing every layer. The decoding is the technique from decoding data source URIs, and it is what makes this safe for GeoPackage layers whose sources carry |layername= suffixes.
Remap paths with a table
Migrations almost always move a prefix: a drive letter becomes a UNC path, a user's home directory becomes a shared folder. An ordered list of prefix pairs handles that and handles exceptions, as long as the longest prefix is tried first.
from qgis.core import QgsDataProvider
PATH_MAP = {
"S:/projects/archive/": "//arch/gis/",
"S:/projects/": "//fileserver/gis/projects/",
}
rules = sorted(PATH_MAP.items(), key=lambda kv: len(kv[0]), reverse=True)
def remap(path):
norm = path.replace("\\", "/")
for old, new in rules:
if norm.lower().startswith(old.lower()):
return new + norm[len(old):]
return None
options = QgsDataProvider.ProviderOptions()
options.transformContext = project.transformContext()
unmapped = []
for layer, parts in missing:
new_path = remap(parts["path"])
if new_path is None or not os.path.exists(new_path):
unmapped.append((layer.name(), parts["path"], new_path))
continue
parts["path"] = new_path
source = registry.encodeUri(layer.providerType(), parts)
layer.setDataSource(source, layer.name(), layer.providerType(), options)
for row in unmapped:
print("UNMAPPED", *row)
Breakdown: Normalising backslashes and comparing case-insensitively matters for Windows paths, where the same folder is written three different ways across a decade of projects. The existence check on the new path is what stops a wrong rule from quietly replacing one broken path with another. setDataSource on a placeholder layer resolves it for real, so after this loop the repaired layers are valid and the unmapped ones are listed for a human.
Database hosts and services
Database layers are repaired the same way, with QgsDataSourceUri in place of the registry. A host rename is the common case; a move to a service= entry in pg_service.conf is the better long-term fix, because the next rename then changes one file instead of every project.
from qgis.core import QgsDataSourceUri
for layer in project.mapLayers().values():
if layer.providerType() != "postgres":
continue
uri = QgsDataSourceUri(layer.source())
if uri.host() == "db-old.internal":
uri.setConnection("db-new.internal", uri.port(), uri.database(),
"", "", uri.sslMode(), uri.authConfigId())
layer.setDataSource(uri.uri(False), layer.name(), "postgres", options)
Breakdown: Rebuilding the connection with empty user and password, and the existing authConfigId(), keeps credentials out of the project; if the old projects had passwords stored inline, this is also the moment to strip them. Checking the old host explicitly means projects that were already pointed at the new server pass through untouched.
Dry-run a whole folder first
A mapping that looks right against one project can be wrong against the fortieth. Before rewriting anything, run the detection and remapping across every project without saving, and collect a report that someone who knows the file server can read.
import csv
from pathlib import Path
def audit(project_path):
project = QgsProject.instance()
project.clear()
project.read(str(project_path), Qgis.ProjectReadFlag.DontResolveLayers)
rows = []
for layer in project.mapLayers().values():
parts = registry.decodeUri(layer.providerType(), layer.source())
path = parts.get("path")
if not path or os.path.exists(path):
continue
target = remap(path)
status = ("fixable" if target and os.path.exists(target)
else "no rule" if target is None else "target missing")
rows.append([project_path.name, layer.name(), path, target or "", status])
return rows
with open("/srv/reports/path_audit.csv", "w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["project", "layer", "old_path", "new_path", "status"])
for qgz in sorted(Path("/srv/projects").rglob("*.qg[sz]")):
writer.writerows(audit(qgz))
Breakdown: Three statuses cover every outcome that matters: fixable rows will be repaired, no rule rows need a new mapping entry, and target missing rows mean a rule exists but the data did not move where the rule says — the most useful finding, because it usually exposes a folder that was left behind. The glob picks up both .qgs and .qgz. Sorting the paths gives a stable report order, so running the audit again after adding rules produces a diff that shows exactly what changed. Only once the no rule and target missing counts are acceptable is it worth switching the same loop over to the repair code.
Save with relative paths
Before writing, switch path storage to relative. Relative paths are resolved against the project file's own folder, so a project and its data can be moved together without breaking.
from pathlib import Path
project.setFilePathStorage(Qgis.FilePathType.Relative)
out = Path("/srv/projects_fixed") / Path(project.fileName()).name
if not project.write(str(out)):
raise RuntimeError(project.error())
print("saved", out)
Breakdown: Relative storage only helps for data that sits on the same drive as the project; a path on another Windows drive letter cannot be expressed relatively and is stored absolute regardless. Writing to a new folder keeps the original as a fallback, and the relative paths are computed against the new location — so copy the data alongside if the fixed project is meant to be portable. Wrapping the whole sequence in a function that takes a project path and a mapping turns it into a batch tool that can walk a directory tree of projects, with the same error handling you would give any unattended script.
QGIS version compatibility
Qgis.ProjectReadFlag.DontResolveLayers is the 3.26+ spelling; on 3.10–3.24 use QgsProject.FlagDontResolveLayers. setFilePathStorage and Qgis.FilePathType arrived in 3.22; earlier releases use project.writeEntryBool("Paths", "/Absolute", False). On the QGIS 4 series only the scoped enum forms are available. setDataSource resolving a placeholder layer works from 3.10 onward.
Troubleshooting
- Reading the project hangs. The flag was not applied and providers are timing out on a dead share. Check the flag name for your version.
- Layers are repaired but styles are default. The project was read normally, a layer failed, and QGIS dropped its renderer. Always read with
DontResolveLayersfor repair work. - Paths still absolute after saving. The data is on a different drive from the project file.
- Some layers repaired, some not. Mixed separators or case in the stored paths; normalise before matching.
- Embedded layers or groups stay broken. They are defined in another project; repair that project first.
Conclusion
Open projects with layer resolution off, decode each source, apply an ordered prefix mapping, verify the new path exists, and use setDataSource so every layer keeps its styling and references. Save with relative paths so that the next reorganisation of the file server is not a repair job.
Frequently Asked Questions
Can I fix projects without QGIS Desktop installed?
Yes. Run the same code in a standalone PyQGIS script with QgsApplication initialised; no GUI is needed.
Should I edit the .qgs XML directly instead?
It works for simple path swaps, but it bypasses provider-specific encoding and breaks on .qgz without unzipping. The API route handles every provider consistently.
What about rasters and GeoPackage rasters?
They use the gdal provider and decode to a path the same way; the loop above handles them unchanged.
How do I stop users saving absolute paths? Set relative storage in a project template, or enforce it with a startup script that connects to the project's write signal.