Batch Reprojecting Raster Datasets in PyQGIS
Reprojecting one raster in the QGIS GUI is trivial; doing it to a hundred DEM tiles, satellite scenes, or land-cover grids by hand is not. When a whole collection has to land in the same target projection — so it aligns with your basemap, your vector data, and every other layer in the project — you want a script that scans a folder, applies one Coordinate Reference System to every file, and writes clean outputs without touching the interface. In PyQGIS the workhorse is processing.run() driving the gdal:warpreproject algorithm inside a loop.
This page shows a correct batch reproject that auto-detects each source CRS, picks resampling that suits the data type, keeps memory and file sizes under control, and validates the results — plus a native GDAL fallback for when the QGIS bindings are unavailable.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) with the Processing framework available.
- A folder of rasters to reproject (single- or multi-band GeoTIFFs work out of the box).
- The QGIS Python Console (
Plugins > Python Console, orCtrl+Alt+P). - GDAL ≥ 3.4 and PROJ ≥ 8.2 with the PROJ data grids installed, so datum shifts transform accurately rather than falling back silently.
Ideally every source file already declares its own projection. Files with missing or broken metadata need extra handling — see Handling Missing CRS in PyQGIS — and the recipe below flags those cases explicitly instead of guessing.
Run a Batch Reproject
Point the script at a source directory and a target EPSG code. It scans for GeoTIFFs, reprojects each one, and logs successes and failures so a single bad file cannot stop the run.
import os
import glob
import processing
# Configuration
INPUT_DIR = "/data/source/rasters"
OUTPUT_DIR = "/data/reprojected/rasters"
TARGET_CRS = "EPSG:3857" # your target projection
RESAMPLING = 1 # 0=Nearest, 1=Bilinear, 2=Cubic, 3=CubicSpline
os.makedirs(OUTPUT_DIR, exist_ok=True)
raster_files = glob.glob(os.path.join(INPUT_DIR, "*.tif"))
for raster_path in raster_files:
out_name = os.path.basename(raster_path).replace(".tif", "_reproj.tif")
out_path = os.path.join(OUTPUT_DIR, out_name)
params = {
"INPUT": raster_path,
"SOURCE_CRS": None, # auto-detect from file metadata
"TARGET_CRS": TARGET_CRS,
"RESAMPLING": RESAMPLING,
"NODATA": None,
"TARGET_RESOLUTION": None,
"OPTIONS": "",
"DATA_TYPE": 0, # 0=Auto (keep source data type)
"OUTPUT": out_path,
}
try:
processing.run("gdal:warpreproject", params)
print(f"Success: {out_name}")
except Exception as exc:
print(f"Failed {raster_path}: {exc}")
Breakdown: glob.glob collects every .tif in the source folder, and each iteration builds an output name so results never overwrite the originals. SOURCE_CRS=None tells GDAL to read the projection from each file's own metadata, while TARGET_CRS is the single destination every file is warped into. RESAMPLING chooses the interpolation kernel, DATA_TYPE=0 preserves the source pixel type, and wrapping processing.run() in try/except keeps the loop alive when one file has a problem.
Resampling is the decision that separates a correct batch from a plausible one, because the wrong method does not fail — it produces values that never existed in the source.
Choose the Right Resampling
The resampling method is the one setting that silently corrupts data if you get it wrong, so match it to what the raster represents:
- Nearest Neighbor (
0) — for categorical rasters such as land cover, soil class, or any classified map. It copies existing pixel values and never invents an intermediate class, so class codes stay valid. - Bilinear (
1) — a good default for continuous data such as elevation, slope, or temperature, giving smooth results at modest cost. - Cubic (
2) / Cubic Spline (3) — smoother still for continuous surfaces where interpolation quality matters more than speed.
Applying Bilinear or Cubic to a categorical raster averages neighbouring class codes into meaningless in-between values, so always drop to Nearest Neighbor for classified data.
Handle Missing Source CRS
SOURCE_CRS=None works only when the file actually carries projection metadata. If a file has none, gdal:warpreproject raises a "CRS not defined" error. Rather than assume a default, read the CRS first and pass it explicitly for that file:
from qgis.core import QgsRasterLayer
layer = QgsRasterLayer(raster_path, "probe")
source_crs = layer.crs().authid() # e.g. "EPSG:32633"
if source_crs:
params["SOURCE_CRS"] = source_crs
else:
print(f"Skipping (no CRS): {raster_path}")
continue
Breakdown: Loading the raster as a QgsRasterLayer lets you query crs().authid() for an authority string. When it comes back empty the file's projection is genuinely unknown, and skipping-and-logging is safer than injecting a guess that would misplace every pixel. This mirrors the detection pattern used across Spatial Data Processing & Automation pipelines.
A batch that stops on its first bad file wastes the run. Recording each outcome and continuing turns a failed batch into a report you can act on.
Tame Large Datasets
Big collections stress memory and hit file-format limits. GDAL tiles rasters into RAM as it warps, so keep roughly twice the size of the largest input free. Pass creation options through the OPTIONS parameter to compress, tile, and lift the classic 4 GB TIFF ceiling:
params["OPTIONS"] = "-co TILED=YES -co COMPRESS=LZW -co BIGTIFF=YES"
TILED=YES stores the output in internal tiles for faster windowed reads, COMPRESS=LZW shrinks it losslessly, and BIGTIFF=YES allows outputs beyond 4 GB. For collections over roughly 50 GB, process in chunks or build a Virtual Raster with gdal:buildvirtualraster first, then warp the VRT so GDAL streams from many files as if they were one. Reprojected tiles then feed cleanly into downstream steps such as Clip a Raster by a Mask Layer in PyQGIS.
QGIS Version Compatibility
The code targets QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | gdal:warpreproject parameters identical; requires GDAL ≥ 3.4 for reliable datum shifts. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Same algorithm ID and parameters; newer bundled GDAL improves nodata and edge handling. |
On very old builds (pre-3.24) TARGET_CRS sometimes had to be a QgsCoordinateReferenceSystem object rather than an authority string; on the current LTR line the string form shown here is stable. The exact GDAL version bundled differs per release, which can affect edge-pixel handling on huge rasters, but the PyQGIS interface is unchanged.
Troubleshooting
CRS not definedon some files. Those inputs carry no projection metadata. Read the CRS withQgsRasterLayer(path, "probe").crs().authid()and pass it asSOURCE_CRS, or skip and log the file — never assume a default.- Outputs drift or misalign across the batch. PROJ datum-shift grids are missing, so GDAL falls back to a coarser transform. Confirm GDAL ≥ 3.4 and PROJ ≥ 8.2 with the proj-data package installed, and verify each output with
gdalinfo. - Categorical rasters come out with garbage values. A continuous kernel was used on classified data. Set
RESAMPLING = 0(Nearest Neighbor). - Out-of-memory or very slow runs. Add
-co TILED=YES -co COMPRESS=LZWtoOPTIONS, keep ~2× the largest file free in RAM, and split extremely large collections into chunks or a Virtual Raster. BIGTIFFerrors on files over 4 GB. Add-co BIGTIFF=YEStoOPTIONSto switch off the classic-TIFF size limit.- Windows path issues. Use raw strings (
r"C:\data") or forward slashes, avoid spaces where possible, and expect network drives to time out on large batches.
The GUI and CLI Fallbacks
If you would rather not script it, the same algorithm runs as a batch job in the interface: Processing Toolbox > GDAL > Warp (Reproject), then right-click the tool and choose Execute as Batch Process. Drag the files in, set the target CRS column, and run. It bypasses Python but offers no programmatic error recovery.
When the QGIS bindings are unavailable entirely, a plain shell loop over native GDAL is a dependable fallback:
mkdir -p reproj_output
for f in source/*.tif; do
gdalwarp -t_srs EPSG:3857 -r bilinear \
"$f" "reproj_output/$(basename "$f" .tif)_reproj.tif"
done
This is the CLI equivalent of the recipe above and useful in headless environments or CI where a full QGIS install is not present.
Conclusion
Batch reprojecting rasters in PyQGIS comes down to a few deliberate choices: let SOURCE_CRS=None auto-detect where metadata exists and read it explicitly where it does not, match RESAMPLING to whether the data is categorical or continuous, and reach for OPTIONS tiling, compression, and BigTIFF once files get large. Wrapping each gdal:warpreproject call in try/except keeps a big run resilient, and a final gdalinfo check confirms every output carries the target CRS exactly. With that in place, an entire folder of mismatched rasters becomes an aligned, analysis-ready collection in one pass.
Frequently Asked Questions
Which resampling method should I choose when batch reprojecting?
Use Nearest Neighbor (RESAMPLING = 0) for categorical rasters such as land cover or classified maps, because it never invents intermediate class values. Use Bilinear (1) or Cubic (2) for continuous data such as elevation or temperature, where smooth interpolation is appropriate. Applying a continuous method to categorical data introduces false pixel values that corrupt later analysis.
What happens if SOURCE_CRS is set to None?gdal:warpreproject tries to auto-detect the source projection from each file's embedded metadata, which works for well-formed GeoTIFFs. If a file lacks projection metadata it raises a "CRS not defined" error; in that case extract the CRS first with QgsRasterLayer(path, "probe").crs().authid() and pass it explicitly. Never assume a default source projection for files of unknown origin.
Why are my outputs drifting or misaligned after reprojection?
Coordinate drift across a batch usually means PROJ datum-shift grids are missing, so GDAL falls back to a less accurate transformation. Confirm GDAL is at least 3.4 and PROJ at least 8.2 with the proj-data package installed. Verify each output with gdalinfo to ensure the embedded CRS matches your target exactly.
How do I reproject very large rasters without exhausting memory?
Keep roughly twice the size of the largest raster free in RAM, and add "-co TILED=YES -co COMPRESS=LZW" to OPTIONS to reduce I/O pressure. For files over 4 GB also add -co BIGTIFF=YES to bypass the classic TIFF size limit. For collections over 50 GB, process in chunks or build a Virtual Raster first.
Can I run this batch script outside the QGIS Python Console?
Yes, but a standalone script must initialize the application context with QgsApplication([], False) and initQgis() before calling processing.run(). Inside the console this setup is already done. If QGIS bindings are unavailable entirely, the native gdalwarp CLI loop shown above is a dependable fallback.