Raster Data Handling with Rasterio: Production Workflows & Pipelines
Rasterio is the pixel-processing tier of the Python geospatial stack, and this guide sits inside Mastering Core Geospatial Python Libraries as the counterpart to the vector-focused guides. Where GeoPandas DataFrames and Shapely geometry operations carry tabular geometry, Rasterio carries the grid: elevation models, multispectral imagery, land-cover classifications, and any other regularly-sampled surface. It wraps GDAL in a context-managed, NumPy-native API so you can read a sub-region of a terabyte-scale scene without loading the whole array, run band arithmetic as vectorized operations, mask against vector boundaries, and write standards-compliant output. This guide covers the full production arc — from environment setup and metadata inspection through band math, CRS alignment, and Cloud Optimized GeoTIFF export — with the memory and projection discipline that separates a reproducible pipeline from an ad-hoc script.
Architecture & Data Structures
Every Rasterio session revolves around a DatasetReader (or DatasetWriter) obtained from rasterio.open(). The object is a thin, lazy handle over a GDAL dataset: opening it reads only the header, not the pixels. Three attributes define the spatial contract of any raster and you will touch them on almost every page:
src.transform— anaffine.Affineobject mapping pixel(row, col)to world(x, y)coordinates. This is the georeferencing; without it a raster is just an array.src.crs— arasterio.crs.CRSdescribing the coordinate reference system the transform lives in.src.profile/src.meta— the full creation recipe (driver, dtype, nodata, count, width, height, transform, crs) you copy forward when writing derived files.
import rasterio
# A Sentinel-2 surface-reflectance scene reprojected to UTM
scene_path = "data/sentinel2_utm33n.tif"
with rasterio.open(scene_path) as src:
print(f"Driver: {src.driver}") # GTiff
print(f"Size: {src.width} x {src.height}")
print(f"Bands: {src.count}") # 1-based band indices
print(f"Dtype: {src.dtypes[0]}") # e.g. uint16
print(f"CRS: {src.crs}") # EPSG:32633
print(f"Transform: {src.transform}") # Affine(10.0, 0.0, ...)
print(f"NoData: {src.nodata}")
Two properties of this model matter for everything downstream. First, band indices are 1-based — src.read(1) is the first band, and passing 0 raises IndexError. Second, the context manager guarantees the underlying file handle and GDAL resources are released on exit, which prevents OS-level file locks during batch processing and keeps GDAL's block cache from ballooning. For a deeper comparison of this API against the raw GDAL bindings, see Rasterio vs GDAL Python Bindings.
Pixel coordinates, world coordinates, and the half-pixel question
The Affine transform is the only thing standing between an array index and a place on Earth, and Rasterio exposes the conversion in both directions. src.index(x, y) turns projected coordinates into (row, col); src.xy(row, col) goes back. The detail that costs people an afternoon is that these are not exact inverses by default, because xy() returns the centre of the pixel while index() floors into whichever pixel contains the point:
import rasterio
with rasterio.open("data/dem_utm33n.tif") as src:
row, col = src.index(692_450.0, 4_981_200.0) # world -> array index (floored)
cx, cy = src.xy(row, col) # array index -> PIXEL CENTRE
ulx, uly = src.xy(row, col, offset="ul") # array index -> upper-left corner
print(row, col) # e.g. 480 249
print(cx - 692_450.0) # a fraction of a pixel, not 0.0
print(src.res) # (10.0, 10.0) — so the offset is under 10 m
Round-tripping through xy() and index() is therefore stable, but comparing xy() output against your original coordinates is not — and a pipeline that snaps sample points by that comparison drifts by half a pixel in each direction. Two related helpers fall out of the same transform: rasterio.windows.from_bounds(*bounds, transform=src.transform) builds a Window from a map extent instead of array offsets, and src.window_transform(win) produces the transform for that sub-array so a clipped output stays georeferenced. Never construct the derived transform by adding offsets to the parent's c and f coefficients by hand.
Metadata that changes the numbers: scales, offsets and tags
A DatasetReader also carries per-band metadata that silently rescales your analysis if you ignore it. src.scales and src.offsets hold the linear packing factors that many surface-reflectance and climate products use to store float measurements as int16 — a Sentinel-2 L2A band with scales=(0.0001,) means the raw 2431 you read is a reflectance of 0.2431, not 2431. Rasterio does not apply these automatically on read(); you must multiply. src.descriptions names each band, src.units records the physical unit where the writer bothered to set one, and src.tags() returns the dataset-level key/value metadata (acquisition date, processing baseline, sensor). src.tags(1) returns the same for band 1. Copying src.profile forward preserves the georeferencing but not these tags, so re-apply them with dst.update_tags(**src.tags()) when a derived product must stay traceable.
Environment Configuration & Dependency Resolution
Rasterio is a compiled extension linked against GDAL, which in turn links PROJ and GEOS. ABI compatibility across these C libraries is the single most common cause of a broken install, so pin the whole stack from one channel rather than mixing pip wheels with system packages.
# conda-forge ships GDAL/PROJ/GEOS binaries built against one another
conda create -n raster-pipeline python=3.12 \
"rasterio>=1.3" "gdal>=3.6" numpy pyproj geopandas shapely rio-cogeo \
-c conda-forge
conda activate raster-pipeline
Production considerations before you process anything at scale:
- Pin the GDAL minor version alongside Rasterio. A mismatched
libgdal.so/gdal.dllsurfaces asImportErroronimport rasterio— see the platform section below for the full diagnosis. - Tune the block cache with
GDAL_CACHEMAX(a plain integer is megabytes; a value with%is a fraction of RAM). Larger caches speed up repeated windowed reads at the cost of memory. - Verify the install end-to-end, not just the import:
python -c "import rasterio; from rasterio.crs import CRS; print(rasterio.__version__, CRS.from_epsg(4326))". If the CRS line fails, PROJ's data directory is misconfigured.
The pip wheels bundle their own GDAL and work well for pure-raster jobs, but the moment you also need GeoPandas or PostGIS drivers in the same environment, conda-forge's single-channel resolution saves hours of ABI debugging.
Which creation options you actually have is a property of the build
Rasterio declares a minimum GDAL, not an exact one, so two environments that both satisfy rasterio>=1.3 can offer different capabilities. Three differences bite in practice. The dedicated COG driver — which writes a compliant Cloud Optimized GeoTIFF in one step instead of the manual tile-and-overview recipe below — arrived in GDAL 3.1, so on anything older driver="COG" fails with an unhelpful driver-not-found error. The ZSTD and LERC compressors are compile-time options: a GDAL built without them accepts compress="zstd" in your profile and then errors at write time, which in a nightly batch means you discover it after the expensive read. And num_threads="ALL_CPUS" on a write is only honoured for compressors that support parallel encoding.
Rather than guessing, ask the driver what it supports before you commit a profile:
import rasterio
print(rasterio.__gdal_version__) # e.g. 3.9.2 — the GDAL Rasterio is linked against
with rasterio.Env() as env:
opts = env.drivers() # driver short name -> long name
print("GTiff" in opts, "COG" in opts) # True True on GDAL >= 3.1
from osgeo import gdal # same environment, same libgdal
spec = gdal.GetDriverByName("GTiff").GetMetadataItem("DMD_CREATIONOPTIONLIST")
print("ZSTD" in spec, "LERC" in spec) # build-dependent, not version-dependent
Two GDAL environment variables are worth setting deliberately in any container image. GDAL_CACHEMAX (megabytes as a plain integer, or a percentage with %) sizes the block cache — the default is small enough that a windowed loop over a striped source re-reads the same compressed strips repeatedly. GDAL_NUM_THREADS=ALL_CPUS parallelises deflate compression on write and warping. Set both per-process in the image, and scope any per-job overrides inside a rasterio.Env() block so they do not leak into the next task in the same worker.
Vectorized Operations & Core Workflow
The canonical Rasterio workflow is: open, validate, read into NumPy, compute vectorized, write back. Because a band read returns a plain ndarray, all band math is ordinary NumPy — no per-pixel Python loops. The function below inspects a raster's spatial contract, fails loudly on a missing CRS, and returns the profile so callers can write derived products with identical georeferencing.
import rasterio
from rasterio.errors import CRSError
def inspect_raster(src_path: str) -> dict:
"""Validate georeferencing and report the array's numeric envelope."""
with rasterio.open(src_path) as src:
# A missing CRS silently corrupts every downstream spatial operation
if not src.crs:
raise CRSError(
f"{src_path} has no CRS. Assign one on open with "
"rasterio.open(path, crs=CRS.from_epsg(32633)) or supply a "
".prj / .aux.xml sidecar before processing."
)
print(f"{src.height} x {src.width}, {src.count} band(s)")
print(f"CRS EPSG: {src.crs.to_epsg()} | pixel size: {src.res}")
# Read one band; nodata is preserved as-is, not masked yet
elevation = src.read(1)
valid = elevation[elevation != (src.nodata if src.nodata is not None else elevation.min())]
print(f"dtype {elevation.dtype}, valid range {valid.min()}–{valid.max()}")
return src.profile.copy()
# profile = inspect_raster("data/dem_utm33n.tif")
Two habits keep this loop reliable. Always carry src.profile forward when writing (profile.update(dtype="float32", count=1)) so the output inherits the driver, nodata, transform, and CRS instead of silently dropping them. And when a band participates in arithmetic, cast to float32 first — integer DEMs and uint16 reflectance overflow or truncate the moment you subtract or divide. The band-selection and memory-safe reading patterns are covered in depth in Reading Multi-Band TIFFs with Rasterio.
Where a single-process windowed loop stops scaling
It helps to know the arithmetic before you hit the wall. A full Sentinel-2 10 m tile is 10,980 × 10,980 pixels; at uint16 that is 241 MB per band, so a twelve-band read materialises 2.9 GB before you have done any maths — and casting to float32 for an index doubles it again. Windowed reads keep the resident set at roughly chunk² × bands × itemsize, which is why a 1024-pixel chunk over four bands stays under 35 MB regardless of how large the scene is.
What the loop does not solve is wall-clock time on a catalogue of scenes. A single Python process spends most of a windowed NDVI run inside GDAL's decompressor, one block at a time, and that is a single core. Scaling out has three rules that are easy to get wrong:
- A
DatasetReaderis not safe to share across threads. GDAL datasets are not thread-safe for concurrent access. Open the file once per thread — the OS page cache means the second open is cheap — rather than passing one handle to a thread pool. - Never hand an open dataset to a
multiprocessingworker. ADatasetReaderholds a C pointer that does not survive pickling, and on a forking start method it survives just enough to corrupt reads. Pass the path and open inside the worker. - The block cache is per process, not per pool. Eight workers each honouring
GDAL_CACHEMAX=1024will try to use 8 GB. Divide the budget by the worker count when you size a container.
The practical crossover: below roughly a dozen scenes, a single windowed loop is simpler and fast enough. Above that, parallelise across files with a process pool rather than across windows within a file, because per-scene work is embarrassingly parallel and needs no shared state. When the same scenes must also be aligned along a time axis, the labelled-array route in xarray & rioxarray raster cubes replaces the loop with a chunked, Dask-scheduled graph.
Geometry / Data Processing Details
Two processing patterns dominate real raster work: clipping a raster to vector boundaries, and computing spectral indices over tiles too large for RAM. Both stay vectorized and both hinge on getting the geometry–pixel relationship right.
Masking a raster to vector boundaries
rasterio.mask.mask burns polygons into the pixel grid, keeping pixels inside the geometry and setting the rest to nodata. It expects GeoJSON-like mappings in the raster's CRS, so reproject the vector first — this is the most common masking bug. Pairing it with Shapely geometry operations lets you validate and repair boundaries before they touch the raster.
import geopandas as gpd
import rasterio
from rasterio.mask import mask
from shapely.geometry import mapping
def clip_raster_to_catchment(raster_path, catchment_path, out_path):
catchment = gpd.read_file(catchment_path)
catchment = catchment[catchment.geometry.is_valid]
if catchment.empty:
raise ValueError("No valid catchment geometries to mask with.")
with rasterio.open(raster_path) as src:
# Reproject vector to the raster CRS — mask() does NOT do this for you
if catchment.crs and not catchment.crs.equals(src.crs):
catchment = catchment.to_crs(src.crs)
shapes = [mapping(geom) for geom in catchment.geometry]
clipped, clipped_transform = mask(
src, shapes, crop=True, all_touched=True, nodata=src.nodata
)
out_profile = src.profile.copy()
out_profile.update(
height=clipped.shape[1],
width=clipped.shape[2],
transform=clipped_transform,
compress="deflate",
tiled=True,
)
with rasterio.open(out_path, "w", **out_profile) as dst:
dst.write(clipped)
Use all_touched=True to keep any pixel the polygon boundary crosses (otherwise thin features lose edge pixels). When a catchment falls entirely outside the raster extent, mask raises ValueError: Input shapes do not overlap raster — catch it and skip. For multipart features, catchment.explode(ignore_index=True) first so each part masks independently. Loading the vector boundaries themselves is a GeoPandas job.
all_touched=True widens the burn to the full boundary.Windowed band math for large scenes
Spectral indices like NDVI are a subtraction and a division — trivial arithmetic, but a full Sentinel-2 tile stack will not fit in memory. Iterate over windows aligned to the file's native tiling and write each result block as you go.
import numpy as np
import rasterio
from rasterio.windows import Window
def ndvi_windowed(scene_path, out_path, chunk=1024):
with rasterio.open(scene_path) as src:
# Sentinel-2 band order here: 4 = Red, 8 = NIR (1-based indices)
red_idx, nir_idx = 4, 8
profile = src.profile.copy()
profile.update(count=1, dtype="float32", nodata=-9999.0)
with rasterio.open(out_path, "w", **profile) as dst:
for row in range(0, src.height, chunk):
for col in range(0, src.width, chunk):
win = Window(
col, row,
min(chunk, src.width - col),
min(chunk, src.height - row),
)
red = src.read(red_idx, window=win).astype("float32")
nir = src.read(nir_idx, window=win).astype("float32")
denom = nir + red
ndvi = np.where(denom == 0, -9999.0, (nir - red) / denom)
dst.write(ndvi.astype("float32"), 1, window=win)
Guarding the 0/0 case with np.where avoids RuntimeWarning: invalid value encountered in divide and keeps nodata explicit. Align chunk to src.block_shapes[0] so each read maps onto native TIFF tiles and you never re-read the same block. Band numbering is sensor-specific — confirm it against the product spec before trusting an index.
Crossing the raster–vector boundary in both directions
rasterio.features is the bridge between the grid and the feature table, and it goes both ways. rasterize() burns attribute values from geometries into a new array on a transform you supply — the standard way to build a zone raster whose pixel values are parcel identifiers, so later analysis is a NumPy bincount rather than a per-polygon mask. shapes() does the inverse, walking a classified array and yielding GeoJSON polygons for each connected run of equal values.
import geopandas as gpd
import numpy as np
import rasterio
from rasterio.features import rasterize, shapes
from shapely.geometry import shape
with rasterio.open("data/landcover_utm33n.tif") as src:
grid_transform, grid_shape = src.transform, (src.height, src.width)
landcover = src.read(1)
raster_crs = src.crs
parcels = gpd.read_file("data/urban_parcels.gpkg").to_crs(raster_crs)
# 1. Vector -> raster: burn a small integer key, never a float id
parcels["zone_key"] = np.arange(1, len(parcels) + 1, dtype="int32")
zone_raster = rasterize(
((geom, key) for geom, key in zip(parcels.geometry, parcels.zone_key)),
out_shape=grid_shape,
transform=grid_transform,
fill=0, # 0 means "no parcel here"
dtype="int32",
all_touched=False, # centre-in-polygon, matching mask() semantics
)
# 2. Raster -> vector: polygonise one land-cover class only
water = (landcover == 5).astype("uint8")
water_polys = [
shape(geom)
for geom, value in shapes(water, mask=water.astype(bool), transform=grid_transform)
if value == 1
]
water_gdf = gpd.GeoDataFrame({"class_id": 5}, geometry=water_polys, crs=raster_crs)
print(len(water_gdf), "water polygons")
Three things go wrong here reliably. The geometries must already be in the raster's CRS — rasterize takes a transform, not a projection, so a mismatched vector burns into empty space and returns an all-fill array with no error. The burn value must fit the dtype you declare: passing a float parcel id into an int32 output truncates it, and passing a value above 255 into uint8 wraps around. And shapes() on an unclassified continuous raster produces one polygon per distinct float value, which on a DEM means millions of single-pixel squares — always classify or threshold first, and pass a boolean mask so background is skipped entirely.
The output of the first direction is exactly what per-zone summaries consume; the statistics themselves belong to zonal statistics and raster sampling. The output of the second is an ordinary vector layer, so run it through the topology validation and repair checks before publishing — polygonised rasters have stair-stepped boundaries and frequently share edges only approximately.
CRS Alignment & Projection Pipeline
Raster reprojection resamples pixels onto a new grid, so it needs a source CRS, a target CRS, and a freshly computed transform. Getting the CRS objects right is where most errors hide, and the rules echo those in Coordinate Systems with PyProj:
- Prefer EPSG codes over PROJ strings.
CRS.from_epsg(32633)is unambiguous; a hand-written+proj=utm +zone=33string drops the datum and silently defaults, which shifts coordinates by tens of metres. Legacy PROJ-string workflows also trip the PROJ 6+ deprecations around+init=epsg:— never use that form. - Axis order is a pyproj/PROJ concern, not Rasterio's. Rasterio always works in
(x, y)/ easting-northing order, but when you buildTransformerobjects yourself for point overlays, passalways_xy=Trueso a geographic CRS like EPSG:4326 does not hand back(lat, lon). - Never reproject to Web Mercator for measurement. EPSG:3857 exists for tiling; its scale distortion makes area and distance meaningless away from the equator. Compute NDVI, slope, or zonal statistics in the scene's native UTM zone (or an equal-area CRS), and reproject only the final display product.
import rasterio
from rasterio.crs import CRS
from rasterio.warp import calculate_default_transform, reproject, Resampling
def reproject_scene(src_path, out_path, dst_epsg=25832):
"""Reproject to an appropriate projected CRS (ETRS89 / UTM 32N here)."""
dst_crs = CRS.from_epsg(dst_epsg)
with rasterio.open(src_path) as src:
transform, width, height = calculate_default_transform(
src.crs, dst_crs, src.width, src.height, *src.bounds
)
profile = src.profile.copy()
profile.update(crs=dst_crs, transform=transform, width=width, height=height)
with rasterio.open(out_path, "w", **profile) as dst:
for band in range(1, src.count + 1):
reproject(
source=rasterio.band(src, band),
destination=rasterio.band(dst, band),
src_transform=src.transform, src_crs=src.crs,
dst_transform=transform, dst_crs=dst_crs,
# bilinear for continuous data; nearest for classes
resampling=Resampling.bilinear,
)
Choose the resampling kernel by data semantics: Resampling.bilinear or cubic for continuous surfaces (elevation, reflectance), and Resampling.nearest for categorical rasters (land cover, classified masks) so class codes are never averaged into nonsense values. When the reprojection also has to survive limited RAM, the windowed strategy in Reprojecting Large Datasets Without Memory Errors applies directly.
Three failure modes cluster around this call. First, nodata has to be declared on both sides: reproject() accepts src_nodata and dst_nodata, and if the source band has nodata=None the warper treats fill as real data and smears it into the valid area along every edge — a one-to-two pixel halo that grows with the resampling kernel width. Copying src.profile carries the nodata forward only when the source actually set it; check src.nodata is not None and pass the value explicitly rather than assuming.
Second, calculate_default_transform picks a resolution for you, derived from the source pixel size warped into the target CRS. That is convenient and almost never what you want when several scenes must align: two adjacent tiles reprojected independently end up on grids offset by a fraction of a pixel, and every later stack, difference or zonal summary is silently interpolated. Pass resolution=(10.0, 10.0) (or explicit dst_width/dst_height) so the whole batch lands on one grid, and reproject a categorical mask with Resampling.nearest even when the paired continuous band uses bilinear.
Third, a UTM zone is a bad target CRS for anything spanning a zone boundary. A catchment straddling the 12°E meridian reprojected into zone 32N pushes the eastern half far outside the zone's valid extent, where scale error grows quickly; the same applies to any scene crossing the antimeridian, where calculate_default_transform can return a bounding box spanning the whole globe. Pick a regional projected CRS or an equal-area projection for those cases — the selection logic is in choosing a UTM zone automatically in Python.
When you only need aligned pixels for one downstream read and not a file on disk, WarpedVRT presents a reprojected view of a dataset without materialising it:
import rasterio
from rasterio.vrt import WarpedVRT
from rasterio.enums import Resampling
with rasterio.open("data/sentinel2_utm33n.tif") as src:
with WarpedVRT(
src,
crs="EPSG:25832",
resampling=Resampling.bilinear,
src_nodata=src.nodata,
nodata=src.nodata,
) as vrt:
# Reads are warped on the fly, window by window — nothing is written
print(vrt.crs, vrt.width, vrt.height)
patch = vrt.read(1, window=rasterio.windows.Window(0, 0, 1024, 1024))
This is the right tool when a pipeline reads a small fraction of a large scene in a foreign CRS: you pay the warp only for the windows you touch. It is the wrong tool when every pixel will be read more than once, because a VRT re-warps on each access instead of caching a materialised grid.
Production Export & Integration
The de-facto delivery format for analysis-ready rasters is the Cloud Optimized GeoTIFF (COG): an internally tiled, overview-bearing GeoTIFF that HTTP range requests can read partially. Writing one is a matter of the right creation options — tiling, compression, and internal overviews.
import rasterio
from rasterio.enums import Resampling
from rasterio.shutil import copy as rio_copy
def write_cog(src_path, cog_path):
with rasterio.open(src_path) as src:
profile = src.profile.copy()
profile.update(
driver="GTiff", tiled=True, blockxsize=512, blockysize=512,
compress="deflate", predictor=2, interleave="pixel",
)
with rasterio.open(cog_path, "w", **profile) as dst:
dst.write(src.read())
# Internal overviews power fast zoomed-out reads
dst.build_overviews([2, 4, 8, 16], Resampling.average)
dst.update_tags(ns="rio_overview", resampling="average")
A short export checklist before shipping:
- Validate COG compliance with
rio cogeo validate output.tif(fromrio-cogeo). Tiling and overview ordering must be correct or clients fall back to full reads. - Match compression to data:
deflatewithpredictor=2for continuous integers/floats;LZWfor classified rasters;WEBP/JPEGonly for visual RGB where lossy is acceptable. - Keep nodata explicit so downstream masking and web viewers render transparency correctly.
Once published to object storage, the same COG feeds partial reads over HTTP — see Windowed Reads from Cloud Optimized GeoTIFF in the ingestion and processing workflows section. For catalogs of rasters queried alongside vector footprints, load the extents into PostGIS and let spatial analysis and advanced query techniques drive selection before you touch pixels.
Windows / Platform Edge Cases & Debugging
Most Rasterio failures are environment failures, and they concentrate on Windows and in cloud runners where the GDAL/PROJ data paths are not where the wheel expects them.
ImportError: DLL load failed/libgdal.so: cannot open— Rasterio and GDAL were built against different versions. Rebuild the environment from a single conda-forge solve; do not mix apipRasterio wheel with a system or OSGeo4W GDAL.CRSError: Invalid projectionorPROJ: proj_create: cannot find proj.db— PROJ cannot find its data directory. SetPROJ_LIB(andGDAL_DATA) to the active environment's share folders, e.g.%CONDA_PREFIX%\Library\share\projon Windows or$CONDA_PREFIX/share/projon Linux/macOS. The same install-and-configure discipline as installing GeoPandas on Windows applies here.- Slow opens over network/cloud storage — set
GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR"to skip directory listing, and read remote COGs with/vsicurl/,/vsis3/, or/vsigs/path prefixes so GDAL issues HTTP range requests instead of downloading whole files. - Silent nodata mismatch — a file with
nodata=Nonetreats fill pixels (often0or-9999) as real data, skewing statistics. Always confirmsrc.nodataand set it explicitly on write. RasterioIOError: Read or write failedmid-batch usually means a leaked handle — always open inside awithblock so a crash in one file does not lock the rest.ValueError: Given nodata value ... exceeds dtype rangeon write — you copied a float profile'snodata=-9999.0onto auint8output. Pick a fill inside the dtype (255foruint8) or keep the outputfloat32.- Writes over ~4 GB fail or produce an unreadable file — plain GeoTIFF uses 32-bit offsets. Add
bigtiff="IF_SAFER"to the profile; the check is on the uncompressed size, so a well-compressed 2 GB output can still trip it. - Reads work locally but stall in a container — the image has no CA bundle, so
/vsicurl/HTTPS requests hang or fail with a TLS error that GDAL reports as a generic open failure. Installca-certificatesand confirmCURL_CA_BUNDLEresolves. - A path with non-ASCII characters raises on Windows — GDAL expects UTF-8 filenames. Set
GDAL_FILENAME_IS_UTF8=YESand passstrpaths (notbytes);pathlib.Pathobjects are accepted byrasterio.openand are the safest form. - Results differ between two machines with the same code — compare
rasterio.__gdal_version__andpyproj.proj_version_strfirst. Different PROJ builds carry different datum-shift grids, which changes reprojected coordinates by up to a metre without any error.
Frequently Asked Questions
When should I stop using Rasterio and move to xarray?
The moment the third dimension becomes meaningful. Rasterio's model is one file, one grid, bands addressed by integer; as soon as you are keeping a dictionary of dates to arrays, or writing if band == 8 in more than one place, you are hand-rolling what a labelled cube gives you for free. The crossover is discussed concretely in xarray vs Rasterio for time-series rasters. For single-scene reads, masking, and writing, Rasterio stays the lighter and more predictable option.
Do I need to reproject a raster before clipping it to a vector layer?
No — reproject the vector instead. rasterio.mask.mask needs the geometries in the raster's CRS, and reprojecting a few hundred polygons is instantaneous, while warping a multi-gigabyte scene resamples every pixel and costs you accuracy you cannot get back. Only reproject the raster when the output itself has to be in a different CRS.
Why is my windowed loop slower than reading the whole file?
Almost always because the windows do not match the file's internal layout. A striped GeoTIFF has block_shapes like (1, 20000) — one row per block — so a square 1024 × 1024 window forces GDAL to decompress 1024 full-width strips and throw most of each away. Check src.block_shapes[0] first; if the source is striped and you need square windows repeatedly, rewrite it once as a tiled file and read from that.
Is src.read() lazy?
rasterio.open() is lazy — it reads the header only. read() is not: it allocates and fills a NumPy array immediately. There is no lazy array in Rasterio's model, which is precisely the gap WarpedVRT, windowed loops, and the Dask-backed cubes in xarray & rioxarray raster cubes exist to fill.
Should I store analysis outputs as COG or as NetCDF/Zarr? COG when the consumer is a map client or another GDAL-based tool reading spatial windows over HTTP, because range requests plus internal overviews are exactly what browsers and tile servers need. NetCDF or Zarr when the consumer slices along time or variable — a COG has no time axis, so a 200-date series becomes 200 files and every temporal query becomes a file listing. The trade-offs across the whole family are in cloud-native geospatial formats.
How do I know a derived raster is still correctly georeferenced?
Assert on the transform and CRS, not on the array. After any write, reopen the output and check dst.crs == src.crs, dst.transform == expected_transform, and that dst.bounds overlaps the input bounds by the amount you intended. A raster that is shifted by one pixel looks completely normal in a viewer and only surfaces as a systematic bias in zonal statistics months later.