Reading Multi-Band TIFFs with Rasterio: Explicit CRS & Memory-Safe Reads
Loading a multi-band GeoTIFF — an RGB ortho, a Sentinel-2 stack, or a set of stacked environmental indices — the naive way silently mishandles band order, drops the coordinate reference system, or exhausts RAM on large scenes. This guide gives GIS analysts and data engineers a production-ready pattern for reading multi-band TIFFs with Rasterio that keeps spatial integrity intact and I/O bounded. It sits under Raster Data Handling with Rasterio in Mastering Core Geospatial Python Libraries.
[2, 3, 4, 8] returns a NumPy array whose axis 0 is renumbered 0..3, alongside the Affine transform and a validated CRS; oversized scenes fall back to windowed reads aligned to block_shapes.Why This Approach / What Goes Wrong
The two failures that dominate multi-band raster work are both silent. First, band indexing: Rasterio uses 1-based band numbers, not the 0-based indexing NumPy programmers reflexively reach for. Passing 0 raises IndexError; worse, assuming band 1 is red when the sensor stored it as blue produces a correct-looking array that is spectrally wrong. Second, the coordinate reference system travels with the file but is easy to lose: a TIFF with a missing or non-standard CRS tag still read()s cleanly, then misaligns the moment you mask it against a vector layer or feed it to a spatial join. Getting the CRS right at read time is the single most common source of downstream error in raster pipelines — see Coordinate Systems with PyProj for the transformation mechanics.
Memory is the third trap. src.read() with no arguments materialises every band as a (bands, height, width) array; a 12-band, 10980×10980 Sentinel-2 tile in uint16 is roughly 2.9 GB before you have done any maths. The correct approach reads only the bands you need, validates the CRS explicitly, and falls back to windowed reads aligned to the file's native tile grid when a scene will not fit in memory.
Prerequisites
rasterio>=1.3(bundles its own GDAL, so no separate install)numpy>=1.26
conda install -c conda-forge "rasterio=1.3.*" "numpy=1.26.*"
The examples assume a multi-band file on disk; substitute your own path for sentinel2_stack.tif.
Step-by-Step Implementation
1. Inspect the file before reading a single pixel. Metadata tells you the band count, data type, CRS, and native tile size — everything the read strategy depends on.
import rasterio
with rasterio.open("sentinel2_stack.tif") as src:
print("bands :", src.count) # e.g. 12
print("dtype :", src.dtypes[0]) # e.g. uint16
print("crs :", src.crs) # e.g. EPSG:32633 (UTM 33N)
print("size (h x w) :", src.height, src.width)
print("block tiling :", src.block_shapes[0]) # e.g. (512, 512) native tiles
print("nodata :", src.nodata)
Band number is not band identity. Nothing in the GeoTIFF specification forces band 4 to be red, and providers disagree: a Sentinel-2 Level-2A product keeps the sensor's own numbering, while a subset written with gdal_translate -b 4 -b 8 renumbers the two survivors to 1 and 2. Read the identity out of the file instead of assuming it.
import rasterio
with rasterio.open("sentinel2_stack.tif") as src:
# Human-readable band names, if the writer bothered to set them
print(src.descriptions) # ('B02 blue', 'B03 green', 'B04 red', 'B08 nir', ...)
# GDAL colour interpretation — the only machine-readable RGB/alpha hint
print(src.colorinterp) # (<ColorInterp.blue: 5>, <ColorInterp.green: 4>, ...)
# Per-band tags carry wavelength, scale, and product-specific keys
print(src.tags(4)) # {'BANDNAME': 'B04', 'WAVELENGTH': '665'}
# Resolve by name, with an explicit fallback you can log
by_name = {name: i for i, name in enumerate(src.descriptions, start=1) if name}
red_index = by_name.get("B04 red")
if red_index is None:
red_index = 4 # documented product default — record that you guessed
src.descriptions is a tuple of None values on most files, because few writers populate it, so treat a hit as a bonus and the fallback as the normal path — but log which branch ran. A pipeline that silently defaults to "band 4 is red" against a scene where it is not produces an NDVI raster that looks entirely plausible and is wrong in every pixel.
2. Read selected bands with explicit CRS validation. This function reads only the bands you ask for, rejects out-of-range indices, and refuses to hand back a raster whose CRS is missing — failing loudly at read time instead of silently later.
import rasterio
from rasterio.crs import CRS
from rasterio.errors import CRSError
import numpy as np
def read_multiband_raster(
file_path: str, bands: list[int] | None = None
) -> tuple[np.ndarray, object, CRS]:
"""Read a multi-band GeoTIFF with explicit CRS validation.
Args:
file_path: Path to the GeoTIFF.
bands: 1-based band indices to read. Defaults to all bands.
Returns:
Tuple of (data array, Affine transform, CRS).
"""
with rasterio.open(file_path) as src:
# Fail loudly on a missing CRS rather than misaligning downstream
if src.crs is None:
raise CRSError(
f"No CRS in {file_path}. Assign one before spatial operations."
)
# Rasterio bands are 1-based; default to every band in the file
bands_to_read = bands if bands else list(range(1, src.count + 1))
for band_index in bands_to_read:
if band_index < 1 or band_index > src.count:
raise IndexError(
f"Band {band_index} out of range; file has "
f"{src.count} band(s), numbered 1..{src.count}."
)
data = src.read(bands_to_read) # (n_bands, height, width)
print(
f"Read {len(bands_to_read)} band(s) | shape {data.shape} | {src.crs}"
)
return data, src.transform, src.crs
# Sentinel-2 blue, green, red, NIR — a common false-colour / NDVI selection
stack, transform, crs = read_multiband_raster(
"sentinel2_stack.tif", bands=[2, 3, 4, 8]
)
The rasterio.open() context manager closes the file handle on exit, which prevents OS-level locks and leaked descriptors during batch processing. src.count detects the band total dynamically, so the same function handles heterogeneous scenes from different sensors. Passing a list to src.read() loads only those bands, and the returned transform (an Affine) is what maps pixel positions to projected coordinates for later masking and vector alignment.
Band selection always cuts memory; it only cuts I/O when the file stores each band as a separate plane. Check the interleave before assuming a four-of-twelve read is three times cheaper:
import rasterio
with rasterio.open("sentinel2_stack.tif") as src:
print(src.tags(ns="IMAGE_STRUCTURE"))
# {'INTERLEAVE': 'BAND', 'COMPRESSION': 'DEFLATE', 'LAYOUT': 'COG'}
INTERLEAVE=BAND (band-sequential) keeps each band contiguous, so GDAL seeks straight to the four planes you named and moves roughly a third of the bytes. INTERLEAVE=PIXEL stores all twelve values for a pixel together, so every band you request drags its eleven neighbours through the decompressor — the resulting array is still small, but the read costs as much as reading everything. If a pixel-interleaved scene is subset repeatedly, rewrite it once with interleave="band" in the output profile and pay the conversion a single time. This is also why band selection and windowing compose: the window bounds the pixels, the interleave decides how many bands come along for the ride.
A CRS caveat worth internalising: a Rasterio CRS object records the authority definition, but the axis order you get when you hand its EPSG code to PyProj depends on the transformer. For EPSG codes that are natively latitude-longitude ordered (like EPSG:4326), pass always_xy=True to pyproj.Transformer.from_crs(...) so coordinates stay in (x, y) order and do not silently swap. Reproject metric analysis into a proper projected CRS such as the scene's UTM zone rather than Web Mercator; the details are covered in Fixing PyProj CRS Transformation Errors.
3. Cast before band math to avoid integer overflow. Multi-band imagery is usually uint16 or int16. Computing an index like NDVI on the raw integers wraps around silently; cast to float32 first.
nir - red on a water pixel wraps to a huge positive number and the index comes out nonsensical rather than raising.import numpy as np
# stack rows are ordered [B2, B3, B4, B8] from step 2
red = stack[2].astype("float32") # band 4 -> index 2 in the subset
nir = stack[3].astype("float32") # band 8 -> index 3 in the subset
ndvi = np.where((nir + red) == 0, 0, (nir - red) / (nir + red))
print("NDVI range:", float(ndvi.min()), "to", float(ndvi.max()))
Note the index shift: once you subset to [2, 3, 4, 8], the returned array is re-numbered 0..3 along axis 0 — the original band numbers no longer apply to the NumPy array.
Rasterio can perform the cast during the read, which matters when the float copy is the allocation that fails: src.read([4, 8], out_dtype="float32") builds the float array once inside GDAL rather than materialising a uint16 array and then a second float32 copy of it, roughly halving the peak footprint of that step. Watch the other direction too — many surface-reflectance products store scaled integers, and read() never applies the scaling for you. Check src.scales and src.offsets; if they are not (1.0, ...) and (0.0, ...), multiply and add them yourself before the index maths, or your NDVI is computed on digital numbers rather than reflectance.
4. Read large scenes with windows aligned to the native tile grid. When a full read would exhaust RAM, stream the raster in blocks. Aligning each Window to src.block_shapes avoids re-reading partial tiles, which is the same principle behind Windowed Reads from Cloud Optimized GeoTIFF.
import rasterio
from rasterio.windows import Window
with rasterio.open("sentinel2_stack.tif") as src:
block_height, block_width = src.block_shapes[0] # native tile size
for row_off in range(0, src.height, block_height):
for col_off in range(0, src.width, block_width):
window = Window(
col_off,
row_off,
min(block_width, src.width - col_off),
min(block_height, src.height - row_off),
)
tile = src.read([4, 8], window=window) # red + NIR only
# ... process tile in place, write out, or accumulate a statistic
For Cloud Optimized GeoTIFFs on object storage, open the same way with a /vsis3/bucket/key.tif or /vsicurl/https://.../scene.tif path — Rasterio issues HTTP range requests so only the touched tiles cross the network.
5. Read at reduced resolution instead of reading every pixel. Thumbnails, quicklooks, coverage checks, and extent previews do not need full resolution. out_shape asks GDAL for a decimated array, and when the file carries an overview pyramid the request is served from the nearest overview level rather than by decimating the full-resolution raster.
import rasterio
from rasterio.enums import Resampling
with rasterio.open("sentinel2_stack.tif") as src:
print(src.overviews(1)) # [2, 4, 8, 16] decimation factors — or [] if none
scale = 16
preview = src.read(
[4, 8],
out_shape=(2, src.height // scale, src.width // scale),
resampling=Resampling.average,
)
# The transform MUST be rescaled to match, or the preview georeferences wrongly
preview_transform = src.transform * src.transform.scale(
src.width / preview.shape[-1], src.height / preview.shape[-2]
)
print(preview.shape, preview_transform.a) # (2, 686, 686) 160.0
Two things decide whether this is cheap. If src.overviews(1) returns an empty list the file has no pyramid, so GDAL still reads every full-resolution pixel and throws most of them away — the array is small but the read is not. And the resampling kernel must match the data: average or bilinear for continuous measurements like reflectance and elevation, nearest or mode for categorical rasters, because averaging land-cover class codes invents classes that do not exist in the legend. Forgetting the transform rescale is the classic bug here: the array comes back at 160 m pixels while the transform still claims 10 m, so the preview plots at one sixteenth of its true size in the corner of the scene.
6. Decide what nodata means before you compute anything. A multi-band file can carry a different nodata value per band, and none of it is applied unless you ask.
import rasterio
with rasterio.open("sentinel2_stack.tif") as src:
print(src.nodatavals) # (0.0, 0.0, 0.0, 0.0) — per band
stack = src.read([4, 8], masked=True) # np.ma.MaskedArray, nodata masked out
footprint = src.dataset_mask() # uint8: 255 where the scene has data
red = stack[0].astype("float32")
nir = stack[1].astype("float32")
ndvi = (nir - red) / (nir + red) # masked cells stay masked through the maths
print("valid pixels:", int(ndvi.count()), "of", ndvi.size)
print("mean NDVI :", float(ndvi.mean())) # computed over valid pixels only
masked=True returns a numpy.ma.MaskedArray whose mask is derived from nodata, and NumPy propagates that mask through arithmetic, so mean(), min(), and max() quietly exclude the fill. Without it, the padding around a rotated or partially-covered scene is a genuine 0, and stack.mean() returns a number that is smaller than the truth by however much of the tile is empty — no warning, no exception, just a statistic that is wrong in proportion to the void. The cost is one boolean plane per band (about one byte per pixel), which is why the pattern belongs on the analysis path and not on a bulk copy. dataset_mask() is the complementary view: one array for the whole dataset, and the only thing that catches files whose validity lives in an alpha band rather than a nodata value.
Verification
Confirm the read returned the bands you asked for, a valid CRS, and a transform whose pixel size is sane. The assertions below fail fast if any invariant is broken.
import rasterio
from rasterio.crs import CRS
stack, transform, crs = read_multiband_raster(
"sentinel2_stack.tif", bands=[2, 3, 4, 8]
)
assert stack.shape[0] == 4, "expected 4 bands in the subset"
assert isinstance(crs, CRS) and crs.to_epsg() is not None, "CRS must resolve to an EPSG code"
assert abs(transform.a) > 0 and abs(transform.e) > 0, "pixel size must be non-zero"
print("OK:", stack.shape, "| EPSG:", crs.to_epsg(), "| px:", transform.a)
# Expected console output, e.g.:
# Read 4 band(s) | shape (4, 10980, 10980) | EPSG:32633
# OK: (4, 10980, 10980) | EPSG: 32633 | px: 10.0
Edge Cases & Debugging
IndexErroron band 0. Rasterio is 1-based; the first band is1, not0. Validate requests againstrange(1, src.count + 1).- Missing or non-standard CRS.
src.crsreturnsNonewhen the file lacks a projection tag. Reopen in write mode withcrs=CRS.from_epsg(32633)to attach one, or verify extents withrasterio.warp.transform_bounds()before trusting the header. MemoryErroron a fullsrc.read(). The scene is larger than RAM; switch to the windowed loop in step 4 rather than reading every band at once.- Silent overflow in band math.
uint16arithmetic wraps at 65535. Cast tofloat32before computing indices, as in step 3. - Slow opens over the network. Set
rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR")to skip directory scans, and use/vsis3/or/vsicurl/paths for range-request reads instead of downloading whole files. - Values are an order of magnitude off. The product stores scaled integers. Inspect
src.scalesandsrc.offsetsand apply them (value * scale + offset) before any index or threshold; reflectance products commonly need a divide by 10,000. - A shared dataset object corrupts reads under threads. An open Rasterio dataset is not thread-safe. Open the file once per worker thread rather than sharing the handle, and set
GDAL_NUM_THREADS=ALL_CPUSin arasterio.Envif you want parallelism inside the decompressor instead. - A window that runs past the edge returns a short array.
Windowis clipped to the dataset by default. Passboundless=Truewith an explicitfill_valuewhen you need a fixed-size tile regardless of where it lands, otherwise the last row and column of a tiled loop come back smaller than the rest and break a stacking step. src.descriptionsis allNone. The writer never set band names. Set them on output withdst.set_band_description(1, "B04 red")so downstream consumers do not have to guess the way you just did.
Frequently Asked Questions
Should I read all the bands once, or re-open and read the bands I need for each computation? Read the subset you need, once, and keep it. A repeated open-and-read costs a fresh header parse and — on object storage — a fresh set of HTTP round trips, which dominates everything else. The exception is the case where the bands for different steps do not overlap and the full set will not fit in RAM together: then two narrow reads beat one wide read that swaps.
How do I work out which band is red when the file has no documentation?
Check in this order: src.descriptions for names the writer set, src.colorinterp for GDAL's RGB and alpha assignments, and src.tags(i) for per-band wavelength or product keys. If all three come back empty you are back on the product specification for that sensor, and the correct move is to hard-code the index with a comment naming the spec — not to infer it from pixel statistics, which fails on any scene dominated by cloud or water.
Is masked=True worth the extra memory?
On the analysis path, yes — it costs about one byte per pixel per band and prevents nodata fill from being averaged into your statistics. On a bulk copy or reprojection path, no: nothing is being reduced, so the mask buys nothing and you are better off carrying the nodata value in the profile and letting the writer honour it.
Do I need a Cloud Optimized GeoTIFF to use windowed reads?
No. Any internally tiled TIFF supports them, and src.block_shapes tells you the tile size to align to. What a COG adds is an overview pyramid plus a header layout that lets a remote client find the right tile in one or two range requests instead of many — which matters over the network, not on local disk. The remote-specific mechanics are in Windowed Reads from Cloud Optimized GeoTIFF; the encode side is covered in Resampling and Overviews When Writing COGs.
When does a windowed loop end up slower than just reading the whole scene? When the scene comfortably fits in memory. Each window is a separate GDAL read that re-enters the decompressor, and on a small file that per-call overhead outweighs the memory saved. As a rule of thumb, read whole below roughly a quarter of available RAM and stream above it; between those, measure rather than guess.
Should I move to a labelled raster cube instead of managing band indices by hand? Once bands acquire names, times, or a third axis you keep re-deriving, yes. Rasterio hands you a bare NumPy array where axis 0 is an anonymous integer, which is exactly the bookkeeping that goes wrong across a multi-date stack. The trade-off between the two models is worked through in xarray vs Rasterio for Time-Series Rasters.