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.
"""
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.
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.gpkgcan 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 switchingactionOnExistingFiletoCreateOrOverwriteLayer.ct— aQgsCoordinateTransform. 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 buildctso the writer and the transform agree on which grid shift to apply.actionOnExistingFile—CreateOrOverwriteFilereplaces the whole GeoPackage;CreateOrOverwriteLayerreplaces one table inside an existing container;AppendToLayerNoNewFieldsadds 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.
"""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.setPrefixPath → QgsApplication([], False) → initQgis()) shown in main() above. The Processing variant additionally needs the Processing providers registered before processing.run() will resolve native:reprojectlayer:
"""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
.prjformat. - Build one
QgsCoordinateTransformper file fromlayer.sourceCrs(). Reusing a single transform across a mixed-CRS folder is the classic batch bug. - Never leave
options.ctunset 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
.shpshould not abandon 400 good files. Return a structured result and log it — ideally throughQgsMessageLogso it lands in the QGIS log panels when run interactively. - Pass the same
transformContextto 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.
Related
- Coordinate Transformations and CRS Handling in PyQGIS — parent guide covering the full transformation pipeline from CRS construction to datum-shift selection
- Troubleshooting CRS Mismatches in PyQGIS Scripts — the diagnostic companion for when a reprojected output lands in the wrong place
- How to Run a Processing Algorithm Headlessly in Python — bootstrapping Processing and running algorithms without a GUI for batch and CI jobs
- Optimizing Feature Iteration with QgsVectorLayer.getFeatures — efficient feature access patterns for the per-file loops inside a batch pipeline