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.

Open without loading, remap, saveFive stages left to right. Read with the do-not-resolve-layers flag so nothing tries to open the missing data. Collect the layers that are not valid. Decode each source with the provider registry. Apply an old-prefix to new-prefix mapping table to the path. Set the new source on the layer and write the project, optionally switching to relative paths.Repair without ever opening the missing data1 readdon't resolvelayers2 findlayers wherenot isValid()3 decodesource intopath + options4 remapold prefix tonew prefix5 applysetDataSourcethen write()mapping tableS:/projects/ → //fileserver/gis/projects/host=db-old → host=db-new

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.

Order the mapping from specific to generalThree sample paths are tested against two rules. The rule for S:/projects/archive/ is more specific than the rule for S:/projects/. The archive path takes the specific rule. An ordinary project path takes the general rule. A path on C: matches neither and is reported as unmapped instead of being changed.Longest matching prefix wins; no match means reportrules, tried in this order1 S:/projects/archive/ → //arch/gis/2 S:/projects/ → //fileserver/gis/S:/projects/archive/1998.shprule 1 → //arch/gis/1998.shpS:/projects/flood/extent.gpkgrule 2 → //fileserver/gis/flood/extent.gpkgC:/Users/amy/Desktop/temp.shpno rule → listed as unmappedtried the other way round, rule 2would swallow the archive pathsort by prefix length, longest 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.

Why relative paths survive a moveA folder holding flood.qgz and a data subfolder is moved from one drive to another. With absolute storage the project still points at the old drive and every layer breaks. With relative storage the project points at ./data/extent.gpkg, which resolves correctly in the new location.Move the folder: which projects still open?absoluteS:/flood/data/extent.gpkgfolder now lives on T:every layer unavailablerelative./data/extent.gpkgresolved from the .qgz locationopens unchanged

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 DontResolveLayers for 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.