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.

PyQGIS batch reprojection data-flowA source folder of GeoTIFFs is scanned by glob into a file list. Inside a per-file loop, the parameters SOURCE_CRS=None (auto-detect), a single TARGET_CRS, and a RESAMPLING kernel feed processing.run for gdal:warpreproject. A try/except guard wraps each call: successful warps are written as _reproj.tif files carrying the target CRS into an output folder, while failures are printed to an error log so one bad file cannot stop the run.Source folderglob(“*.tif”)for each raster (loop)SOURCE_CRS = None(auto-detect per file)TARGET_CRS · RESAMPLINGprocessing.run(“gdal:warpreproject”, params)try / exceptone bad file never stops the runOutput folder*_reproj.tifcarries target CRSError logprint(failed path)successon error

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, or Ctrl+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.

Why categorical rasters must use nearest neighbourA land-cover raster with classes one, two and five is resampled. Nearest neighbour keeps only existing class values. Bilinear averaging produces intermediate values such as three point five, which correspond to no class at all. On a continuous elevation raster the same averaging is correct and produces a smoother surface.Averaging class codes invents classes that do not existland cover · nearest125152every value is a real classland cover · bilinear1.43.13.82.23.52.9class 3.5 does not existelevation · bilinear312.4318.9325.1315.8321.6328.3intermediate values are valid

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.

Abort on error versus collect and continueEight input rasters are processed. In the abort-on-error run, file three fails and the remaining five are never attempted, so the run produces two outputs and no information about the rest. In the collect-and-continue run, file three is recorded as failed and the other seven are processed, producing seven outputs and a named failure to investigate.One corrupt file should not cost you the whole runabort on error2 written, 5 never attemptedcollect and continue7 written, 1 named failure to investigate↑ the bad file

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 versionPythonNotes
3.28 LTR3.9gdal:warpreproject parameters identical; requires GDAL ≥ 3.4 for reliable datum shifts.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Same 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 defined on some files. Those inputs carry no projection metadata. Read the CRS with QgsRasterLayer(path, "probe").crs().authid() and pass it as SOURCE_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=LZW to OPTIONS, keep ~2× the largest file free in RAM, and split extremely large collections into chunks or a Virtual Raster.
  • BIGTIFF errors on files over 4 GB. Add -co BIGTIFF=YES to OPTIONS to 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.