Rasterio vs GDAL Python Bindings
Both Rasterio and the raw osgeo.gdal bindings sit on the same C library, yet they offer very different developer experiences — and picking the wrong one leaves you either fighting verbose boilerplate or missing a GDAL feature Rasterio never wrapped. This guide compares the two for day-to-day raster work and shows exactly when the lower-level bindings still earn their place. It is for anyone scripting raster I/O who wants a defensible default. It sits under Raster Data Handling with Rasterio in Mastering Core Geospatial Python Libraries.
Why This Approach / What Goes Wrong
Rasterio and osgeo.gdal both call libgdal, so neither is faster at the metal — the same C code decodes the same bytes. The difference is entirely ergonomics and safety, and that is where the naive "just use whatever the tutorial showed" approach goes wrong.
The raw bindings mirror GDAL's C API one-to-one. You call gdal.Open, fetch a band object, call band.ReadAsArray(), and read georeferencing as a bare six-tuple geotransform that you index by position. Nothing owns the dataset for you: you must assign dataset = None to flush pending writes and release the file handle. Forget it — inside a loop, after an exception, on an early return — and you leak handles or, worse, silently truncate an output raster that was never flushed to disk. There is no context manager, no Affine object, no exception on a bad band index; you get C-style return values and your own discipline.
Rasterio wraps that same engine in Python idioms. with rasterio.open(...) as src closes the dataset deterministically even on an exception; src.read() hands you a NumPy array directly; src.transform is an Affine object you can multiply, invert, and reason about; src.crs is a first-class CRS object rather than a WKT string you must parse. Windowed reads use a named Window, masks are explicit, and errors raise Python exceptions. For reading, windowing, band math, and writing derived rasters — the overwhelming majority of raster work, including everything covered in Reading Multi-Band TIFFs with Rasterio — Rasterio is the correct default.
The raw bindings still win in one situation: you need a GDAL capability Rasterio does not expose directly. VRT construction (gdal.BuildVRT), specific gdal.Warp resampling options, algorithms like gdal.FillNodata, or driver creation options only reachable through the C API. The right pattern is not "pick one library for the whole project" — it is "use Rasterio by default and drop to raw GDAL for the single step that needs it," because both read and write the same files with the same GDAL underneath.
The GDAL version you have changes two of these arguments
Much of the folklore about the raw bindings dates from GDAL 2.x, and two of its strongest claims have expired. GDAL 3.7 added an explicit Dataset.Close() so you no longer have to lean on the ds = None idiom, and it also started emitting FutureWarning: Neither gdal.UseExceptions() nor gdal.DontUseExceptions() has been explicitly called — a signpost that GDAL 4.0 will raise Python exceptions by default. GDAL 3.8 went further and gave Dataset real __enter__/__exit__ methods, so with gdal.Open("elevation_utm.tif") as ds: is valid modern code.
That matters for how you write the comparison into a codebase. On GDAL ≥ 3.8 with gdal.UseExceptions() called at import, the raw bindings are no longer meaningfully leak-prone; on the GDAL 3.4–3.6 builds still shipped by long-term-support Linux distributions, the same line dies with AttributeError: __enter__. If your library must run across both, keep the try/finally form rather than the context manager, because a version check at runtime is more code than the finally it replaces.
What no GDAL release has changed is the shape of the API. Georeferencing still comes back as a bare six-tuple you index by position, the CRS still arrives as a WKT string rather than an object, bands are still handles you fetch before you can read, and there is still no profile-style dict that carries driver, dtype, nodata, transform and CRS forward in one argument. Those are the ergonomics that make Rasterio the default, and they are structural rather than versioned.
Performance is not part of the decision. Both APIs bottom out in the same GDALRasterIO call against the same block cache, so a src.read(1, window=win) and a band.ReadAsArray(xoff, yoff, xsize, ysize) over the same window move identical bytes in identical time. The only throughput differences you can measure come from how often you cross the Python/C boundary — reading a raster row by row is slow in both libraries, and reading it block-aligned is fast in both.
The one thing that genuinely trips people crossing between the two is georeferencing. GDAL's geotransform and Rasterio's Affine describe the same mapping from pixel to world coordinates, but they order their six coefficients differently, so copying numbers between them without converting silently shifts or flips your raster.
(c, a, b, f, d, e) and Rasterio's Affine stores them as (a, b, c, d, e, f) — the same mapping, reordered. Affine.from_gdal() does the reshuffle correctly; zipping the tuples by index does not.Prerequisites
rasterio>=1.3gdal>=3.8(provides theosgeo.gdalbindings)numpy>=1.26
conda install -c conda-forge "rasterio=1.3.*" "gdal=3.8.*" "numpy=1.26.*"
Install both from conda-forge in the same environment so they share one GDAL build. Rasterio bundles its own copy of GDAL, and if a separately installed osgeo.gdal reports a different version, the two can disagree on driver behaviour and nodata handling for the identical file — see the version-mismatch note under Edge Cases.
Step-by-Step Implementation
1. Read a band and its georeferencing — the Rasterio way.
import rasterio
with rasterio.open("elevation_utm.tif") as src:
dem = src.read(1) # NumPy ndarray, shape (rows, cols)
transform = src.transform # Affine object: transform * (col, row) -> (x, y)
crs = src.crs # CRS object, e.g. EPSG:25832
nodata = src.nodata # float | None
# File handle released here, even if the block raised
print(dem.shape, crs) # (1800, 2400) EPSG:25832
2. The same read with the raw GDAL bindings.
from osgeo import gdal
gdal.UseExceptions() # opt into Python exceptions; the default is silent None returns
ds = gdal.Open("elevation_utm.tif")
band = ds.GetRasterBand(1)
dem = band.ReadAsArray() # NumPy ndarray
geotransform = ds.GetGeoTransform() # (x0, dx, 0, y0, 0, dy) — index by POSITION
wkt = ds.GetProjection() # CRS as a WKT string, not a CRS object
nodata = band.GetNoDataValue()
ds = None # MUST release explicitly to flush + close
Note gdal.UseExceptions() — without it the raw bindings return None on failure and let your script march on with a null dataset. Rasterio raises by default, which is one less footgun.
with block closes and flushes on the unwind, while a bare ds = None is simply skipped, which is how the raw bindings leave a truncated raster with no error.3. Convert the geotransform to an Affine — do not copy coefficients by hand.
from affine import Affine
# GDAL geotransform is (c, a, b, f, d, e); Affine wants (a, b, c, d, e, f).
# Affine.from_gdal() reorders correctly — never zip the tuples yourself.
transform = Affine.from_gdal(*ds.GetGeoTransform())
x, y = transform * (0, 0) # upper-left corner in projected coordinates
This is the bridge that keeps the two APIs interoperable. Because a raster's CRS lives in the file, both libraries agree on it; align or reproject between rasters using the transformer patterns in Coordinate Systems with PyProj rather than mutating a geotransform by hand.
4. Write a derived raster — Rasterio clones the profile.
import rasterio
with rasterio.open("elevation_utm.tif") as src:
profile = src.profile.copy() # dtype, crs, transform, nodata, driver...
slope = (src.read(1) * 0.1).astype("float32")
profile.update(dtype="float32", count=1)
with rasterio.open("slope_utm.tif", "w", **profile) as dst:
dst.write(slope, 1) # georeferencing carried over intact
The profile dict carries CRS, transform, dtype, and nodata forward automatically. The equivalent in raw GDAL means calling driver.Create(...), then SetGeoTransform, SetProjection, WriteArray, SetNoDataValue, and finally ds = None — five explicit calls where Rasterio needs one **profile.
5. Reach for raw GDAL only for the step Rasterio does not wrap — e.g. building a VRT mosaic.
from osgeo import gdal
# VRT mosaicking is a GDAL-native operation with no direct Rasterio API.
# Do this one step in GDAL, then open mosaic.vrt with Rasterio for everything after.
gdal.BuildVRT("mosaic.vrt", ["tile_north.tif", "tile_south.tif", "tile_east.tif"])
The VRT is just another dataset both libraries can open, so the next line of your pipeline can go straight back to rasterio.open("mosaic.vrt"). For range-request reads over the network, the same principle applies — see Windowed Reads from Cloud Optimized GeoTIFF.
6. Use gdal.Warp when you need a cutline and a snapped output grid in one pass.
rasterio.warp.reproject handles the reprojection itself, but it does not expose GDAL's cutline clipping or its target-aligned-pixels grid snapping. When a municipal orthophoto has to land on a 25 cm grid whose pixel edges are multiples of the resolution — the requirement for tiles that mosaic cleanly with a neighbouring authority's delivery — gdal.Warp does it in a single call:
from osgeo import gdal
gdal.UseExceptions()
gdal.Warp(
"ortho_25832_aligned.tif",
"ortho_utm33.tif",
dstSRS="EPSG:25832", # ETRS89 / UTM 32N — metric, not 3857
cutlineDSName="municipality_boundary.gpkg",
cropToCutline=True,
targetAlignedPixels=True, # snap the output grid to xRes/yRes multiples
xRes=0.25, yRes=0.25,
resampleAlg="cubic", # nearest for classified rasters
dstNodata=-9999,
creationOptions=["TILED=YES", "COMPRESS=DEFLATE", "BIGTIFF=IF_SAFER"],
)
Then hand the result straight back to Rasterio. The cutline layer is read by OGR, so it must carry a CRS; if it does not, GDAL assumes it is already in the source raster's CRS and clips the wrong area without complaining.
7. Keep configuration scoped — rasterio.Env beats gdal.SetConfigOption.
GDAL configuration options are process-global when set through gdal.SetConfigOption, which means one function quietly changes the behaviour of every later read in the same interpreter — a nasty failure mode in a long-running worker or a notebook. Rasterio wraps the same options in a context manager that restores the previous values on exit:
import rasterio
from rasterio.env import Env
from rasterio.windows import Window
with Env(
GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR", # skip the directory listing on object storage
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif", # never fetch sidecars that do not exist
GDAL_CACHEMAX=512, # megabytes of block cache for this block only
):
with rasterio.open("/vsicurl/https://data.example.org/ortho_cog.tif") as src:
tile = src.read(1, window=Window(0, 0, 512, 512))
# outside the Env, GDAL_CACHEMAX is whatever it was before — nothing leaked
print(tile.shape) # (512, 512)
Both libraries read the same /vsicurl/, /vsis3/ and /vsimem/ virtual file systems, which is also the cleanest way to pass data between them without touching disk: write to /vsimem/scratch.tif with one API and open the same path with the other.
Verification
Confirm both APIs read identical pixels and identical georeferencing from the same file — proof that the choice is ergonomic, not semantic.
import numpy as np
import rasterio
from osgeo import gdal
from affine import Affine
gdal.UseExceptions()
with rasterio.open("elevation_utm.tif") as src:
rio_arr = src.read(1)
rio_transform = src.transform
ds = gdal.Open("elevation_utm.tif")
gdal_arr = ds.GetRasterBand(1).ReadAsArray()
gdal_transform = Affine.from_gdal(*ds.GetGeoTransform()) # reorder before comparing
ds = None
assert np.array_equal(rio_arr, gdal_arr), "same file, same bytes expected"
assert rio_transform == gdal_transform, "transforms must match once reordered"
print("match:", rio_arr.shape, rio_transform.to_gdal()[:2])
# match: (1800, 2400) (399960.0, 10.0)
If the two arrays match but the transforms do not, you compared Rasterio's Affine against GDAL's raw tuple without reordering — that is the single most common false alarm here.
Edge Cases & Debugging
- Leaked file handles or a truncated output raster. You forgot
dataset = Nonewith the raw bindings; a partially written file with no error is the classic symptom. Rasterio'swithblock closes and flushes deterministically — prefer it for any write path. - Geotransform vs
Affineconfusion. GDAL's(c, a, b, f, d, e)orders differently from Rasterio's(a, b, c, d, e, f). Convert withAffine.from_gdal()/transform.to_gdal(); never zip the six numbers by index. - Silent failures in raw GDAL. By default
gdal.OpenreturnsNoneinstead of raising. Callgdal.UseExceptions()at import time so a missing file or unreadable band throws like Rasterio already does. ImportError: osgeo. The GDAL Python bindings are not installed; theconda-forgegdalpackage providesosgeo.gdal. A barepip install gdalfrequently fails to build against the system library.- Version mismatch between the two GDALs. Rasterio ships its own GDAL; a separately installed
osgeo.gdalmay be a different version, causing subtly different nodata or driver behaviour on one file. Install both fromconda-forgein one environment and checkrasterio.__gdal_version__againstgdal.__version__. - NumPy dtype surprises. Both return the band's native dtype (
uint16,int16,float32…); cast explicitly with.astype("float32")before arithmetic to avoid integer overflow in index or slope math. AttributeError: __enter__onwith gdal.Open(...). The bindings predate GDAL 3.8, which is whenDatasetgained context-manager support. Usetry/finallywithds = Noneinstead of pinning a newer GDAL just for the syntax.FutureWarningaboutUseExceptionson every import. GDAL 3.7+ warns when neithergdal.UseExceptions()norgdal.DontUseExceptions()has been called, because 4.0 flips the default. Callgdal.UseExceptions()once at module import and the warning disappears along with the silent-Noneclass of bug.- Output larger than 4 GB fails or truncates. Classic GeoTIFF is a 32-bit-offset format. Pass
BIGTIFF=IF_SAFERas a creation option in either library —creationOptions=[...]in raw GDAL,bigtiff="IF_SAFER"(or the same key in**profile) in Rasterio. - Band index
0. Rasterio raisesIndexErrorbecause bands are 1-based;ds.GetRasterBand(0)returnsNoneunlessgdal.UseExceptions()is active, and the next line then fails with an unrelatedAttributeErroronNoneType. - Config options that "randomly" stop applying. You mixed a global
gdal.SetConfigOptionwith arasterio.Envblock; leaving theEnvrestores the value it captured on entry and overwrites your global. Set everything throughEnv, or everything globally — not both.
Frequently Asked Questions
Should I rewrite an existing osgeo.gdal script in Rasterio?
Not as a project in itself. A working GDAL script reads the same bytes and writes the same files, so a rewrite buys readability, not correctness. The exception is any script with a write path that lacks try/finally — those really do leave truncated rasters when an upstream read fails, and porting just the write into a with rasterio.open(..., "w", **profile) block removes the whole failure class in a few lines.
Is one faster than the other for large reads?
No, and benchmarks that claim otherwise are usually measuring block alignment rather than the API. Both call the same GDALRasterIO against the same cache. If a read is slow in Rasterio it will be equally slow in raw GDAL; the fix in both is to align your window to src.block_shapes[0] (Rasterio) or band.GetBlockSize() (GDAL) so each request maps onto whole internal tiles.
Can I hand a Rasterio dataset to a GDAL function without writing to disk?
Yes — go through GDAL's in-memory virtual file system. Write with rasterio.io.MemoryFile() and pass its .name (a /vsimem/... path) to gdal.Warp or gdal.BuildVRT, or write directly to /vsimem/scratch.tif from either side. Both libraries link the same VSI layer, so the path resolves in either process-local namespace as long as it is the same interpreter.
Which should a new team standardise on?
Rasterio as the house style, with raw GDAL allowed as a named exception for VRT building, gdal.Warp cutlines, gdal.FillNodata, and driver options Rasterio does not surface. Write the exception list into the contribution guide, because the failure mode in practice is not "the wrong library" — it is half the codebase in each, with geotransforms copied between them by index.
Does Rasterio expose everything gdal.Translate does?
Most of it, spread across different entry points: subsetting is a Window, band selection is the indexes argument to read(), format conversion and creation options go through rasterio.shutil.copy(src, dst, driver="COG", **opts). What is genuinely missing is the composite behaviour — scaling, expanding a palette to RGB, and re-encoding in a single call — which is why gdal.Translate remains the shorter route for one-shot conversions.