Reading Cloud Optimized GeoTIFFs with rasterio Windows
A windowed read is the operation that makes cloud-native geospatial work: given a URL and a study area, fetch the bytes covering the study area and nothing else. It is three lines of rasterio when it goes right and a full scene transfer when it goes wrong, and the difference is entirely in how the window is derived and how GDAL is configured. This guide covers that narrow step inside Cloud-Native Raster Workflows for Forestry, part of Ecological GIS Data Foundations in Python.
When to use this approach
| Read pattern | Bytes moved | Use when |
|---|---|---|
src.read() on a remote COG |
The whole scene | Never, unless you genuinely need every pixel |
src.read(window=...) |
The overlapping internal tiles | A study area smaller than the scene — the normal case |
src.read(out_shape=...) |
The matching overview level | A preview, a quicklook, or a coarse regional summary |
rasterio.mask.mask(..., crop=True) |
The geometry’s bounding window | You want the clip and the mask in one step |
| Download, then read locally | The whole scene, once | You will read the same scene many times and disk is cheaper than repeat requests |
Minimal reproducible example
The core is rasterio.windows.from_bounds, which converts map coordinates into a pixel window. Everything difficult is on either side of it: the bounds have to be in the raster’s CRS, and the window has to be snapped so the read lands on whole internal tiles.
import geopandas as gpd
import rasterio
from rasterio.windows import from_bounds
GDAL_ENV = {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
"CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif,.TIF,.tiff",
"GDAL_CACHEMAX": "512",
"VSI_CACHE": "TRUE",
}
def read_stand_window(cog_url: str, stands_path: str, band: int = 1):
"""Read only the pixels covering a stand layer from a remote COG."""
stands = gpd.read_file(stands_path)
with rasterio.Env(**GDAL_ENV), rasterio.open(cog_url) as src:
# The bounds MUST be expressed in the raster's own CRS, not the vector's.
minx, miny, maxx, maxy = stands.to_crs(src.crs).total_bounds
window = from_bounds(minx, miny, maxx, maxy, transform=src.transform)
window = window.round_offsets().round_lengths()
# Clip to the raster so a partly-overlapping stand layer does not error.
window = window.intersection(
rasterio.windows.Window(0, 0, src.width, src.height))
data = src.read(band, window=window)
transform = src.window_transform(window)
profile = src.profile | {
"height": data.shape[0], "width": data.shape[1],
"transform": transform, "count": 1,
}
return data, profile
The to_crs(src.crs) call is the line that most often goes missing. Passing latitude and longitude bounds to a raster in UTM does not raise — from_bounds happily computes a window somewhere near the origin, the read succeeds, and you get a small array of NoData that looks like a genuinely empty stand.
Read at the right resolution
For a regional overview you rarely need full resolution. Passing out_shape makes GDAL serve the request from the nearest overview level, which is both faster and far smaller — a 10× decimation reads roughly 1% of the bytes.
def read_overview(cog_url: str, bounds, decimation: int = 8):
"""Read a decimated window, served from the COG's overview pyramid.
`bounds` must already be expressed in the raster's own CRS.
"""
with rasterio.Env(**GDAL_ENV), rasterio.open(cog_url) as src:
window = from_bounds(*bounds, transform=src.transform).round_offsets().round_lengths()
out_h = max(1, int(window.height) // decimation)
out_w = max(1, int(window.width) // decimation)
data = src.read(
1, window=window, out_shape=(out_h, out_w),
resampling=rasterio.enums.Resampling.average,
)
transform = src.window_transform(window) * rasterio.Affine.scale(
window.width / out_w, window.height / out_h)
return data, transform
Use Resampling.average when decimating a continuous surface such as reflectance or height, and Resampling.nearest for a classified raster, where averaging class codes produces values that mean nothing.
Parameter reference
| Setting | Where | Recommended | Why |
|---|---|---|---|
GDAL_DISABLE_READDIR_ON_OPEN |
env | EMPTY_DIR |
Stops a directory listing on every open — frequently the dominant cost |
CPL_VSIL_CURL_ALLOWED_EXTENSIONS |
env | .tif,.TIF,.tiff |
Prevents speculative requests for sidecar files that do not exist |
GDAL_CACHEMAX |
env | 512 (MB) | Holds decoded tiles so overlapping windows are not re-fetched |
VSI_CACHE / VSI_CACHE_SIZE |
env | TRUE / 128 MB |
Caches raw byte ranges, which helps when reading several bands of one scene |
GDAL_HTTP_MULTIPLEX |
env | YES |
Allows HTTP/2 multiplexing where the host supports it |
window |
src.read |
rounded outward | Snaps the read to whole internal tiles |
out_shape |
src.read |
decimated | Serves from overviews rather than full resolution |
boundless |
src.read |
False |
True silently pads outside the raster; prefer an explicit intersection |
Expected output and verification
The correctness question is whether the window you read covers the geometry you asked for. Compare the georeferenced bounds of the returned array against the geometry’s bounds in the same CRS:
import rasterio
from rasterio.transform import array_bounds
def verify_window(data, profile, wanted_bounds) -> dict:
"""Confirm the returned array actually covers the requested extent."""
got = array_bounds(profile["height"], profile["width"], profile["transform"])
covers = (got[0] <= wanted_bounds[0] + 1e-6 and got[1] <= wanted_bounds[1] + 1e-6
and got[2] >= wanted_bounds[2] - 1e-6 and got[3] >= wanted_bounds[3] - 1e-6)
return {
"shape": tuple(data.shape),
"got_bounds": got,
"covers_request": bool(covers),
"all_nodata": bool((data == profile.get("nodata")).all()),
}
report = verify_window(data, profile, wanted_bounds)
assert report["covers_request"], "window does not cover the request — check the CRS of the bounds"
assert not report["all_nodata"], "everything is NoData — the window is probably in the wrong CRS"
The all_nodata assertion is the one that catches the missing to_crs. A window computed from degrees against a metre-based transform lands in a corner of the scene that is almost always empty, so the read succeeds and returns a perfectly valid array of nothing.
To confirm the read is actually windowed rather than pulling the whole file, measure it. GDAL’s curl layer reports bytes transferred when asked:
import time
import rasterio
def measure_read(cog_url: str, bounds) -> dict:
"""Time a windowed read and report the array size, as a proxy for bytes moved."""
t0 = time.perf_counter()
with rasterio.Env(**GDAL_ENV, CPL_CURL_VERBOSE="NO"), rasterio.open(cog_url) as src:
window = from_bounds(*bounds, transform=src.transform).round_offsets().round_lengths()
data = src.read(1, window=window)
full_px = src.width * src.height
dt = time.perf_counter() - t0
read_px = int(data.shape[0]) * int(data.shape[1])
return {"seconds": round(dt, 2), "read_px": read_px, "scene_px": full_px,
"fraction": round(read_px / full_px, 4)}
A windowed read of a small stand from a Sentinel-2 tile should complete in a second or two over a normal connection. If it takes a minute, the file is almost certainly not a COG — a striped GeoTIFF cannot serve a window, so GDAL falls back to reading everything.
Common pitfalls
- Bounds in the wrong CRS. The most common failure and the quietest: no exception, a valid array, and every pixel NoData.
- Reading band by band without caching. Four bands of one scene means four sets of range requests unless
VSI_CACHEis on; with it, the header and shared tiles are fetched once. - Using
boundless=Trueto avoid an error. It pads outside the raster with NoData, so a study area that only half-overlaps a scene returns an array that looks complete and is half fabricated. Intersect explicitly instead. - Assuming every remote GeoTIFF is a COG. Many public archives still serve striped files.
gdalinforeportsBlock=...x1for a striped raster andBlock=512x512for a tiled one. - Opening inside a loop without
rasterio.Env. The GDAL settings are per-environment; opening outside one silently uses defaults and the directory listing returns.
Frequently Asked Questions
How do I check whether a remote file is really a COG?
gdalinfo /vsicurl/<url> reports the block size and whether overviews exist. Block=512x512 with an Overviews: line is a COG; Block=...x1 means strips and no windowed read is possible. The rio cogeo validate command from rio-cogeo gives a definitive answer including layout order.
Should I read with rasterio.mask instead of a window?
rasterio.mask.mask(src, shapes, crop=True) does the window and the geometry mask in one call and is the better choice when you want both. It reads the geometry’s bounding window, so the byte cost is the same; the difference is only whether you get the pixels outside the polygon masked for you.
Why is my first read slow and later reads fast?
The header and tile index are fetched on open and then cached, so the first request to a scene carries that overhead. Within one rasterio.Env and one process the cache persists; across processes it does not. Reading several windows from the same scene inside a single environment is markedly cheaper than opening it repeatedly.
Related
- Cloud-Native Raster Workflows for Forestry — the wider pattern this read step belongs to
- Searching STAC Catalogs for Sentinel-2 with pystac-client — where the asset URLs come from
- Masking Rasters to Stand Boundaries with rasterio.mask — clipping to a polygon rather than a rectangle
- Coordinate Reference Systems for Forestry — the CRS discipline the window depends on
Up: Cloud-Native Raster Workflows · Ecological GIS Data Foundations in Python