Batch Reprojecting a Directory of Shapefiles in PyQGIS

Reproject a whole folder of shapefiles to a target CRS in PyQGIS: walk the directory, build a QgsCoordinateTransform per source CRS, and write GeoPackage…

TL;DR: Glob the .shp files with pathlib, load each one with the ogr provider, then call QgsVectorFileWriter.writeAsVectorFormatV3() with a SaveVectorOptions carrying a per-file QgsCoordinateTransform (built from layer.sourceCrs() to your target CRS) plus a populated QgsCoordinateTransformContext, writing a GeoPackage copy of each layer and logging any file that fails.

This page is part of the Coordinate Transformations and CRS Handling in PyQGIS guide, which sits inside the broader PyQGIS Core Architecture & Data Handling reference. Where the companion page Troubleshooting CRS Mismatches in PyQGIS Scripts focuses on diagnosing a single broken transform, this one is a production how-to: point it at a folder, name a target CRS, and get a clean set of reprojected outputs with a failure log you can act on.

Complete Runnable Batch Reprojector

The function below walks a directory tree, reprojects every shapefile it finds to a target CRS (EPSG:25832 / ETRS89 UTM zone 32N in this example), and writes each result as a GeoPackage. It builds a fresh QgsCoordinateTransform per file because different shapefiles in the same folder often carry different source projections. Nothing is hard-coded to a single input CRS — each layer is transformed from whatever sourceCrs() reports.

python
"""
batch_reproject_dir.py

Reproject every shapefile in a directory tree to a single target CRS
and write GeoPackage copies. Standalone-capable (no QGIS Desktop).

Compatible with QGIS 3.28+ LTR / PROJ 8+.

Usage (standalone):
    python batch_reproject_dir.py
"""
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

from qgis.core import (
    QgsApplication,
    QgsCoordinateReferenceSystem,
    QgsCoordinateTransform,
    QgsCoordinateTransformContext,
    QgsProject,
    QgsVectorFileWriter,
    QgsVectorLayer,
)


@dataclass
class BatchResult:
    """Outcome of a directory-wide reprojection run."""

    written: list[Path] = field(default_factory=list)
    failed: list[tuple[Path, str]] = field(default_factory=list)

    def summary(self) -> str:
        """Return a one-line human-readable summary."""
        return f"{len(self.written)} written, {len(self.failed)} failed"


def reproject_directory(
    input_dir: Path,
    output_dir: Path,
    target_epsg: int,
    *,
    transform_context: QgsCoordinateTransformContext | None = None,
    recursive: bool = True,
) -> BatchResult:
    """
    Reproject every shapefile under ``input_dir`` to ``target_epsg``.

    Each file is transformed from its own ``sourceCrs()`` — the batch does
    not assume a shared input projection. Output is written as GeoPackage,
    one .gpkg per source shapefile, mirroring the input file stem.

    Args:
        input_dir:         Directory to scan for .shp files.
        output_dir:        Directory to write .gpkg outputs into (created if absent).
        target_epsg:       Destination CRS as an EPSG integer, e.g. 25832.
        transform_context: Datum-shift preferences. Defaults to the active
                           project context so grid-based shifts are honoured.
        recursive:         Recurse into subdirectories when True.

    Returns:
        BatchResult listing successful outputs and (path, reason) failures.
    """
    target_crs = QgsCoordinateReferenceSystem(f"EPSG:{target_epsg}")
    if not target_crs.isValid():
        raise ValueError(f"EPSG:{target_epsg} is not a recognised CRS.")

    ctx = transform_context or QgsProject.instance().transformContext()
    output_dir.mkdir(parents=True, exist_ok=True)

    result = BatchResult()
    pattern = "**/*.shp" if recursive else "*.shp"

    for shp_path in sorted(input_dir.glob(pattern)):
        layer = QgsVectorLayer(str(shp_path), shp_path.stem, "ogr")
        if not layer.isValid():
            result.failed.append((shp_path, "layer failed to load"))
            continue

        src_crs = layer.sourceCrs()
        if not src_crs.isValid():
            result.failed.append((shp_path, "source layer has no valid CRS"))
            continue

        # Nothing to do if the file is already in the target CRS — copy in
        # place semantics are cheaper and avoid a needless transform pass.
        transform = QgsCoordinateTransform(src_crs, target_crs, ctx)
        if not transform.isValid():
            result.failed.append(
                (shp_path, f"invalid transform {src_crs.authid()} -> {target_crs.authid()}")
            )
            continue

        out_path = output_dir / f"{shp_path.stem}.gpkg"
        options = QgsVectorFileWriter.SaveVectorOptions()
        options.driverName = "GPKG"
        options.layerName = shp_path.stem
        options.fileEncoding = "UTF-8"
        options.ct = transform
        options.transformContext = ctx
        options.actionOnExistingFile = (
            QgsVectorFileWriter.CreateOrOverwriteFile
        )

        error, message, _new_path, _new_layer = (
            QgsVectorFileWriter.writeAsVectorFormatV3(
                layer,
                str(out_path),
                ctx,
                options,
            )
        )

        if error == QgsVectorFileWriter.NoError:
            result.written.append(out_path)
        else:
            result.failed.append((shp_path, f"writer error {error}: {message}"))

    return result


def main() -> None:
    """Bootstrap QGIS, run the batch, and report results."""
    QgsApplication.setPrefixPath("/usr", True)
    qgs = QgsApplication([], False)
    qgs.initQgis()
    try:
        outcome = reproject_directory(
            input_dir=Path("/data/input"),
            output_dir=Path("/data/output_utm32"),
            target_epsg=25832,
        )
        print(outcome.summary())
        for path, reason in outcome.failed:
            print(f"  FAILED {path.name}: {reason}")
    finally:
        qgs.exitQgis()


if __name__ == "__main__":
    main()

Point input_dir at your folder, set target_epsg, and run. Every valid shapefile becomes a GeoPackage in output_dir; anything that could not be loaded, lacked a CRS, or produced an invalid transform is collected in result.failed rather than crashing the run.

How the Pipeline Fits Together

The batch is a straight pipeline with one loop body per file. The only branching is the guard rails — skip a file, log why, and move on — so the whole run survives a single corrupt .shp or a missing .prj.

Batch Reprojection Pipeline A pipeline diagram: scan the directory for shapefiles, then for each file load the layer with the ogr provider, read its source CRS, build a QgsCoordinateTransform to the target CRS, and write a GeoPackage. Failures at any stage are diverted to a failure log. Scan dir glob *.shp for each shapefile Load layer ogr provider Read CRS sourceCrs() Transform to target CRS Write .gpkg Failure log (skip & record)

Architecture Breakdown

QgsVectorFileWriter.writeAsVectorFormatV3 and SaveVectorOptions

writeAsVectorFormatV3() is the current, non-deprecated entry point for writing a QgsVectorLayer to disk (the V2 variant is deprecated from QGIS 3.20, and the original static overloads earlier still). Its signature is compact — the layer, the destination path, a QgsCoordinateTransformContext, and a SaveVectorOptions object — with everything else configured on the options:

  • driverName — the OGR/GDAL driver. "GPKG" for GeoPackage is the recommended default; "ESRI Shapefile" and "GeoJSON" are also common. GeoPackage sidesteps the shapefile’s 10-character field-name truncation and 2 GB size ceiling.
  • layerName — the table name inside the GeoPackage. One .gpkg can hold many layers; here we write one layer per file, but you could aggregate an entire folder into a single container by reusing the path and switching actionOnExistingFile to CreateOrOverwriteLayer.
  • ct — a QgsCoordinateTransform. Setting this is what actually reprojects the geometries. If you leave it unset, the writer copies coordinates verbatim and stamps them with the target CRS, silently corrupting your data.
  • transformContext — the datum-shift context. Pass the same context you used to build ct so the writer and the transform agree on which grid shift to apply.
  • actionOnExistingFileCreateOrOverwriteFile replaces the whole GeoPackage; CreateOrOverwriteLayer replaces one table inside an existing container; AppendToLayerNoNewFields adds features to an existing table.

The call returns a 4-tuple in V3: (error, errorMessage, newFilename, newLayer). Always compare error against QgsVectorFileWriter.NoError — a non-zero code with a populated message is how the writer reports a driver problem, a locked output file, or an encoding failure without raising.

Handling per-file source CRS differences

The single most important design decision in a directory batch is to not assume every shapefile shares one input projection. A folder assembled from mixed sources routinely mixes EPSG:4326, national grids, and various UTM zones. Reading layer.sourceCrs() inside the loop and constructing a fresh QgsCoordinateTransform(src_crs, target_crs, ctx) per file makes each conversion correct regardless of what the neighbouring files hold.

sourceCrs() returns the CRS embedded in the data (the .prj for a shapefile), not any on-the-fly reprojection state — which is exactly what you want in an automated run. If a file has no .prj, sourceCrs() will be invalid; the batch records it as a failure rather than guessing, because guessing a CRS is the fastest way to ship silently wrong coordinates.

Validation before and after the run

Guard every file at load time (layer.isValid()) and at CRS time (src_crs.isValid()), and verify transform.isValid() before writing. These three checks catch the overwhelming majority of batch failures — a corrupt shapefile, a missing .prj, or an unsupported CRS pair — and route them into the failure log instead of aborting the run.

For output verification, reopen a sample of the written GeoPackages and confirm authid() matches the target and that a known feature lands where expected. When a transform succeeds but the numbers look wrong, the problem is almost always a datum-shift or axis-order subtlety rather than the batch mechanics — that is diagnosis territory, and the companion Troubleshooting CRS Mismatches in PyQGIS Scripts walks through the control-point assertion that pins it down.

Alternative: the native:reprojectlayer Processing Algorithm

If you would rather lean on the Processing framework than drive QgsVectorFileWriter directly, native:reprojectlayer does the transform-and-write in one call and honours the same transform context. It is the natural choice when the batch is one stage of a larger Processing pipeline.

python
"""Reproject one file via the Processing framework."""
from pathlib import Path

import processing
from qgis.core import QgsCoordinateReferenceSystem, QgsProject


def reproject_via_processing(src: Path, dst: Path, target_epsg: int) -> str:
    """
    Reproject a single vector file to target_epsg using native:reprojectlayer.

    Returns the output path reported by the algorithm.
    """
    params = {
        "INPUT": str(src),
        "TARGET_CRS": QgsCoordinateReferenceSystem(f"EPSG:{target_epsg}"),
        "OPERATION": "",  # let PROJ pick, or pin a specific pipeline string
        "OUTPUT": str(dst),
    }
    context = QgsProject.instance().transformContext()  # noqa: F841 — see below
    feedback = None
    return processing.run("native:reprojectlayer", params)["OUTPUT"]

The OPERATION parameter accepts an explicit PROJ pipeline string when you need to force a particular datum transformation rather than let PROJ choose the highest-accuracy option it can find. Wrapping this in the same pathlib directory walk as the writer-based version gives you a batch with fewer moving parts, at the cost of a little less control over the output driver options.

Registration and Integration: Running It Headlessly

Batch jobs like this belong in CI, cron, or a container rather than the QGIS Desktop Python console, so they need the standalone bootstrap (QgsApplication.setPrefixPathQgsApplication([], False)initQgis()) shown in main() above. The Processing variant additionally needs the Processing providers registered before processing.run() will resolve native:reprojectlayer:

python
"""Register the native Processing provider for headless use."""
from qgis.analysis import QgsNativeAlgorithms
from qgis.core import QgsApplication


def init_processing() -> None:
    """Load native algorithms so processing.run() can find them headlessly."""
    import processing
    from processing.core.Processing import Processing

    Processing.initialize()
    QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms())

Call init_processing() after initQgis() and before your first processing.run(). The end-to-end mechanics of driving Processing algorithms without a GUI — argument marshalling, feedback objects, and exit handling — are covered in How to Run a Processing Algorithm Headlessly in Python.

Production Best Practices

  • Prefer GeoPackage over shapefile output. It removes the 10-character field-name limit, the 2 GB ceiling, and the multi-file sidecar sprawl, and it stores CRS as WKT2 rather than the lossy older .prj format.
  • Build one QgsCoordinateTransform per file from layer.sourceCrs(). Reusing a single transform across a mixed-CRS folder is the classic batch bug.
  • Never leave options.ct unset when the CRS changes. Without it the writer relabels coordinates instead of transforming them, producing data that looks fine until someone overlays it.
  • Collect failures, do not raise on the first one. A single unreadable .shp should not abandon 400 good files. Return a structured result and log it — ideally through QgsMessageLog so it lands in the QGIS log panels when run interactively.
  • Pass the same transformContext to both the transform and the writer so a deliberately chosen NTv2 grid shift is not quietly downgraded to an approximate Helmert during the write.
  • Skip files already in the target CRS when the folder is partly reprojected already — compare src_crs.authid() to the target and copy rather than re-transform to avoid needless precision loss.
  • Verify a sample of outputs. Reopen a few GeoPackages, assert authid() and a control coordinate, and treat any deviation as a datum-shift investigation, not a batch bug.
  • Pin your PROJ grid files in the container or environment so every run of the batch selects the same transformation pipeline and produces byte-reproducible output.