Resampling with GDAL Warp vs rasterio reproject
Both call the same warp engine; the results match when the options match:
gdalwarp -t_srs EPSG:32636 -tr 10 10 -tap -r bilinear \
-srcnodata 0 -dstnodata 0 -multi -wo NUM_THREADS=ALL_CPUS \
-co TILED=YES -co COMPRESS=ZSTD in.tif out.tif
reproject(rasterio.band(src, 1), rasterio.band(dst, 1),
dst_crs="EPSG:32636", dst_transform=aligned_transform,
resampling=Resampling.bilinear, src_nodata=0, dst_nodata=0,
num_threads=os.cpu_count())
The choice is about where the step lives in a pipeline, not about output quality. This page belongs to handling pixel resolution and scaling in Core Raster Fundamentals & STAC Mapping.
One Engine, Three Front Ends
Knowing that removes a surprising amount of confusion. When two tools produce different outputs from the same input, the question is never “which algorithm is better” but “which option did one of them set that the other did not” — and the answer is almost always one of three: target alignment, the approximation threshold, or nodata.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
| GDAL | >=3.6 |
gdalwarp and the shared warp engine |
rasterio |
>=1.3.0 |
warp.reproject and calculate_default_transform |
rioxarray |
>=0.15 |
rio.reproject on labelled arrays |
numpy |
>=1.23 |
Comparing outputs |
pip install "rasterio>=1.3.0" "rioxarray>=0.15" "numpy>=1.23"
Complete Working Example
import os
import subprocess
import numpy as np
import rasterio
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.warp import reproject, transform_bounds
def aligned_grid(src_path: str, dst_crs: str, res: float):
"""Target transform and shape snapped to multiples of res — gdalwarp's -tap."""
with rasterio.open(src_path) as src:
l, b, r, t = transform_bounds(src.crs, dst_crs, *src.bounds, densify_pts=21)
l, b = np.floor(l / res) * res, np.floor(b / res) * res
r, t = np.ceil(r / res) * res, np.ceil(t / res) * res
return from_origin(l, t, res, res), int(round((t - b) / res)), int(round((r - l) / res))
def warp_python(src_path: str, dst_path: str, dst_crs: str, res: float,
method: Resampling = Resampling.bilinear) -> None:
transform, h, w = aligned_grid(src_path, dst_crs, res)
with rasterio.open(src_path) as src:
profile = src.profile | {"crs": dst_crs, "transform": transform, "width": w,
"height": h, "tiled": True, "compress": "zstd"}
with rasterio.open(dst_path, "w", **profile) as dst:
for b in range(1, src.count + 1):
reproject(rasterio.band(src, b), rasterio.band(dst, b),
src_crs=src.crs, src_transform=src.transform,
dst_crs=dst_crs, dst_transform=transform,
src_nodata=src.nodata, dst_nodata=src.nodata,
resampling=method, num_threads=os.cpu_count(),
warp_mem_limit=512, init_dest_nodata=True)
def warp_cli(src_path: str, dst_path: str, dst_crs: str, res: float,
method: str = "bilinear") -> None:
subprocess.run([
"gdalwarp", "-overwrite", "-t_srs", dst_crs, "-tr", str(res), str(res), "-tap",
"-r", method, "-multi", "-wo", "NUM_THREADS=ALL_CPUS", "-wm", "512",
"-co", "TILED=YES", "-co", "COMPRESS=ZSTD", src_path, dst_path,
], check=True)
if __name__ == "__main__":
warp_python("B11_20m.tif", "B11_py.tif", "EPSG:32636", 10.0)
warp_cli("B11_20m.tif", "B11_cli.tif", "EPSG:32636", 10.0)
with rasterio.open("B11_py.tif") as a, rasterio.open("B11_cli.tif") as b:
assert a.transform == b.transform, (a.transform, b.transform)
diff = np.abs(a.read(1).astype("float64") - b.read(1).astype("float64"))
print("max abs difference:", diff.max())
aligned_grid reproduces what -tap does in the command-line tool: it snaps the target bounds to multiples of the resolution so that outputs from different inputs share a grid. Omitting it in the Python path while using -tap on the command line is the single most common reason the two outputs differ — by a fraction of a pixel in origin, which makes every value differ slightly.
Where Outputs Diverge
The error threshold is the subtlest. GDAL approximates the coordinate transformation with linear interpolation across small regions and recomputes exactly only when the approximation error exceeds a threshold — an eighth of a pixel by default in gdalwarp. That is invisible in almost every use, but it means two runs with different thresholds give values differing in the last decimal places, especially across large, strongly curved warps. Set it explicitly on both sides if byte-identical output matters, or to zero for an exact transform at some cost in speed.
Choosing Between Them
The tools are interchangeable in output, so the choice is about pipeline shape. Use the command-line tool when the step is a file-to-file conversion with nothing downstream in Python — a batch job converting an archive, a shell script, a container entry point. Use rasterio.warp.reproject when the result is consumed as arrays immediately afterwards, because warping directly into a NumPy destination avoids writing and re-reading an intermediate file. Use rioxarray when the data is already a labelled DataArray and you want the coordinates and CRS carried automatically, as in aligning two rasters with reproject_match.
Whichever you pick, express the target grid explicitly rather than letting the tool derive it. A grid derived from each input’s own bounds differs from file to file, so outputs that should stack do not — the underlying reason -tap and explicit transforms exist at all.
Verification
import numpy as np
import rasterio
with rasterio.open("B11_py.tif") as a, rasterio.open("B11_cli.tif") as b:
assert a.transform == b.transform and a.shape == b.shape
da, db = a.read(1, masked=True), b.read(1, masked=True)
assert (da.mask == db.mask).all(), "nodata footprints differ"
diff = np.abs(da.astype("float64") - db.astype("float64"))
print(f"max {diff.max():.6f}, mean {diff.mean():.8f}")
Mapping the difference raster rather than only summarising it is the fastest diagnosis: a stripe along nodata edges names the nodata option, a gradient growing outward names the error threshold, and a uniform speckle across the whole image names alignment.
Common Errors
Outputs differ everywhere by small amounts
The target grids are offset by a fraction of a pixel. Use -tap on the command line and an explicitly snapped transform in Python.
A one-pixel halo of differences along edges
One side passed nodata to the warp and the other did not. Pass source and destination nodata to both.
The Python warp is much slower
num_threads was left at one. Pass it explicitly, along with a warp_mem_limit large enough for the chunk.
Classes change after resampling
Bilinear or cubic was used on categorical data. Use nearest or mode, as covered in choosing the right resampling method for Sentinel-2.
Frequently Asked Questions
Q: Do gdalwarp and rasterio reproject give the same result? Yes, when every option is matched — both call the same GDAL warp engine. Differences almost always trace to one side using a default the other overrides: target alignment, the approximation error threshold, or nodata handling.
Q: Which is faster? They are the same engine, so equally fast with equal settings. gdalwarp exposes multithreading and warp memory flags directly; in rasterio pass num_threads and warp_mem_limit to reproject to get the same behaviour.
Q: When is the command-line tool the better choice? For one-off conversions, shell pipelines and batch jobs where no further Python processing follows. For anything that reads the result back into arrays, rasterio avoids a round trip through disk and keeps the whole step testable in Python.
Q: Can GDAL’s Python bindings be used instead?
Yes — gdal.Warp accepts the same options as the command line as keyword arguments and runs in-process. It is a reasonable middle ground when you want gdalwarp’s option names inside Python without shelling out.
Related
- Handling Pixel Resolution and Scaling — the parent topic.
- Resampling Sentinel-2 20m Bands to 10m — a common case where these options matter.
- Reprojecting a Raster from UTM to WGS84 with rasterio — the reprojection form of the same call.
- Matching Landsat and Sentinel-2 Grids — explicit target grids across sensors.