Windowed Reads from Cloud Optimized GeoTIFF
A Cloud Optimized GeoTIFF lets you read a 512×512 patch from a 40 GB raster on object storage without downloading the file. This guide shows the windowed-read pattern with Rasterio, including reading by geographic coordinates rather than raw pixel offsets, and the PyProj reprojection that keeps your query window landing where you intend. It is for anyone working with satellite imagery, orthophotos, or DEMs too large to localize. It sits under Cloud-Native Geospatial Formats in Geospatial Data Ingestion & Processing Workflows.
Why This Approach / What Goes Wrong
A COG is internally tiled with overviews, and its TIFF header maps each tile to a byte range through the TileOffsets/TileByteCounts arrays. Rasterio — via GDAL's /vsicurl/ virtual filesystem — reads a Window by issuing HTTP range requests for just the tiles that overlap your area of interest: seconds and megabytes instead of minutes and gigabytes. The whole point is to never localize the file.
Three things defeat this, and they account for almost every "my windowed read is still slow/wrong" report.
- The file is not actually a COG. A plain striped GeoTIFF stores pixels in scanline strips with no tile offset table, so GDAL must read sequentially from the start of the image to reach your rows. A "windowed" read then transfers most of the file. Only an internally tiled GeoTIFF with overviews supports true random access.
- Pixel offsets are guessed instead of derived. A
Windowis expressed in pixel row/column space. To read a real-world bounding box you must convert geographic bounds to pixels through the dataset's affine transform; hand-pickingcol_off/row_offreads the wrong ground area. - The query CRS does not match the raster CRS. If your bounds are lon/lat (EPSG:4326) but the raster is a projected UTM grid, the numbers are not comparable and the window lands somewhere else entirely — the single most common failure, and the reason a reprojection step is mandatory. When that reprojection itself misbehaves, see fixing PyProj CRS transformation errors.
There is a fourth, subtler cost that only shows up once the first three are fixed: the transfer is quantised by the internal block size, not by your window. A tile is the smallest unit GDAL can decompress, so a 300×300-pixel window over a 512×512-tiled COG still fetches and decodes four full tiles — roughly 1 MB of pixels to hand you 90,000. The read is correct, just less efficient than the numbers suggest, and the ratio gets worse the more small scattered windows you issue. Below the block size, asking for less costs the same; the lever is fetching fewer, larger, block-aligned windows rather than many tiny ones.
Layered on top of that is GDAL's own chunking. /vsicurl/ does not request exactly the byte ranges the driver asks for; it fetches in fixed chunks (CPL_VSIL_CURL_CHUNK_SIZE, 16 KB by default) and caches them. A 400 KB tile therefore becomes dozens of small HTTP requests unless the chunk size is raised or consecutive ranges are merged. On a high-latency link that request count, not the byte count, is what you are actually waiting for.
Prerequisites
rasterio>=1.3— bundles a GDAL with/vsicurl/and internal-overview supportpyproj>=3.4— reprojects query bounds into the raster CRS when they differ
conda install -c conda-forge "rasterio=1.3.*" "pyproj=3.4.*"
Install from conda-forge rather than pip so GDAL, PROJ, and the Rasterio binding stay ABI-compatible — the same environment discipline used across Raster Data Handling with Rasterio.
Step-by-Step Implementation
1. Configure GDAL for efficient remote reads, then open the COG. The two options below stop GDAL from listing the whole bucket and from probing sidecar files it will never find over HTTP — both otherwise add a round trip per open.
import rasterio
from rasterio.env import Env
gdal_opts = {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR", # don't list the bucket on open
"CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif", # skip probing for .aux/.ovr sidecars
"GDAL_HTTP_MULTIRANGE": "YES", # coalesce tile fetches into one request
"GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES", # adjacent tiles become one range
"CPL_VSIL_CURL_CHUNK_SIZE": "1048576", # 1 MB chunks instead of the 16 KB default
"GDAL_CACHEMAX": "512", # MB of decoded block cache per process
"GDAL_HTTP_MAX_RETRY": "3", # ride out transient 503s
"GDAL_HTTP_RETRY_DELAY": "1",
}
cog_url = "https://example-bucket.s3.amazonaws.com/sentinel_ortho_cog.tif"
with Env(**gdal_opts):
with rasterio.open(cog_url) as src:
print(src.profile["driver"], src.shape, src.crs) # GTiff (20000, 20000) EPSG:32633
print(src.block_shapes[0]) # (512, 512) — internally tiled
print(src.overviews(1)) # [2, 4, 8, 16] — pyramid present
If block_shapes reports a full-width strip like (1, 20000) or overviews(1) is empty, the file is not a COG — jump to the debugging section before going further.
Two of those options are worth understanding rather than pasting. CPL_VSIL_CURL_CHUNK_SIZE sets the granularity at which /vsicurl/ fetches and caches; the 16 KB default is tuned for reading a header, not for pulling 400 KB tiles, so raising it to 1 MB typically cuts the request count for a single window read by an order of magnitude. Push it much higher and you start over-fetching neighbours you never asked for, which hurts when windows are sparse. GDAL_CACHEMAX is the decoded-block cache, in megabytes per process — raise it when you read many overlapping windows from the same file, leave it alone when each window is visited once, and remember that it is per process, so sixteen worker processes at 512 MB will try to use 8 GB.
2. Convert a geographic bounding box to a pixel window. Reproject the AOI bounds into the raster's CRS first, then let from_bounds derive the pixel Window from the affine transform. Pass always_xy=True so PyProj keeps lon/lat (x/y) order and does not silently swap the axes — the classic PROJ 6+ axis-order trap.
from rasterio.windows import from_bounds
from pyproj import Transformer
# Area of interest in EPSG:4326 (lon, lat): a slice of central Berlin
aoi_4326 = (13.35, 52.50, 13.42, 52.54) # (min_lon, min_lat, max_lon, max_lat)
with Env(**gdal_opts), rasterio.open(cog_url) as src:
# Reproject AOI corners into the raster CRS (here EPSG:32633, UTM 33N — a metric grid)
to_raster = Transformer.from_crs("EPSG:4326", src.crs, always_xy=True)
xmin, ymin = to_raster.transform(aoi_4326[0], aoi_4326[1])
xmax, ymax = to_raster.transform(aoi_4326[2], aoi_4326[3])
window = from_bounds(xmin, ymin, xmax, ymax, transform=src.transform)
aoi_patch = src.read(1, window=window) # only overlapping tiles transfer
patch_transform = src.window_transform(window) # georeferencing for the patch
3. Read a decimated overview when full resolution is more than you need. For a wide-area preview, pass out_shape so GDAL pulls a coarser pyramid level and moves a fraction of the bytes — invaluable for thumbnails or when the AOI spans thousands of pixels.
with Env(**gdal_opts), rasterio.open(cog_url) as src:
# Ask for the same window at ~1/8 resolution — reads an overview, not full-res tiles
preview = src.read(
1,
window=window,
out_shape=(int(window.height // 8), int(window.width // 8)),
)
print(preview.shape) # e.g. (18, 39) — a cheap decimated view of the AOI
out_shape makes GDAL serve the request from a pre-built pyramid level, so an eight-fold decimation moves roughly one sixty-fourth of the bytes.4. Snap the window to whole internal blocks so no tile is decoded twice. from_bounds returns a floating-point window that almost never aligns with the 512-pixel grid. Rounding it out to full blocks costs you a few extra pixels and saves partial-tile handling, which matters when you loop over many adjacent windows: unaligned windows make neighbouring reads re-fetch the same boundary tiles.
from rasterio.windows import from_bounds, round_window_to_full_blocks, intersection, Window
with Env(**gdal_opts), rasterio.open(cog_url) as src:
raw = from_bounds(xmin, ymin, xmax, ymax, transform=src.transform)
# Expand outward to the internal tile grid, then clip to the raster extent
aligned = round_window_to_full_blocks(raw, src.block_shapes)
aligned = intersection(aligned, Window(0, 0, src.width, src.height))
print(raw) # Window(col_off=8134.2, row_off=3901.7, width=312.4, height=148.9)
print(aligned) # Window(col_off=7680.0, row_off=3584.0, width=1024.0, height=1024.0)
block_patch = src.read(1, window=aligned)
The intersection call is not optional. round_window_to_full_blocks happily returns offsets past the raster edge for an area of interest that overhangs the image, and read on an out-of-range window raises WindowError rather than clipping for you.
5. Read past the edge deliberately with a boundless read. When the area of interest genuinely straddles the raster boundary — a tile-aligned processing grid, or an AOI drawn before anyone checked the footprint — boundless=True returns an array of exactly the requested shape, padding the outside with fill_value. Without it you get a smaller array than you asked for, and every downstream index is off by the difference.
import numpy as np
with Env(**gdal_opts), rasterio.open(cog_url) as src:
overhanging = Window(col_off=src.width - 200, row_off=0, width=512, height=512)
padded = src.read(1, window=overhanging, boundless=True, fill_value=0)
print(padded.shape) # (512, 512) — full shape, not (512, 200)
print(int((padded == 0).sum())) # 159744 — the padded region outside the raster
Mask the fill before computing statistics; a fill_value of 0 is a legitimate reflectance or elevation value, so treat the padded cells as nodata explicitly with np.ma.masked_equal or by reading src.read_masks.
6. Persist the patch as its own small GeoTIFF, carrying the correct transform. Write patch_transform, not the source transform, or the patch is georeferenced to the parent raster's origin.
with Env(**gdal_opts), rasterio.open(cog_url) as src:
profile = src.profile.copy()
profile.update(
width=aoi_patch.shape[1],
height=aoi_patch.shape[0],
transform=patch_transform,
count=1,
)
with rasterio.open("aoi_patch.tif", "w", **profile) as dst:
dst.write(aoi_patch, 1)
Multi-band imagery follows the identical pattern with src.read(window=window) returning a (bands, rows, cols) stack — see reading multi-band TIFFs with Rasterio for band-indexing details.
7. Fan out over many windows or many scenes with a thread pool. The dominant cost of a remote windowed read is network latency, not CPU, and GDAL releases the GIL while waiting on HTTP — so threads scale this workload well. The one hard rule is that a dataset handle is not thread-safe: open a fresh handle inside each worker rather than sharing one across the pool.
from concurrent.futures import ThreadPoolExecutor
sample_windows = [Window(c, r, 512, 512) for r in range(0, 4096, 512)
for c in range(0, 4096, 512)]
def read_window(win):
# One dataset handle per call — sharing a handle across threads corrupts reads
with Env(**gdal_opts), rasterio.open(cog_url) as src:
return win, src.read(1, window=win)
with ThreadPoolExecutor(max_workers=8) as pool:
patches = dict(pool.map(read_window, sample_windows))
print(len(patches), "windows read concurrently") # 64 windows read concurrently
Eight to sixteen workers is the useful range against a single object store. Beyond that the bucket begins returning 503 SlowDown, and because each worker carries its own GDAL block cache, memory grows linearly with the pool. If the windows all come from the same file, reopening per call is cheap once the header is in the /vsicurl/ cache; if they come from hundreds of different scenes, the per-open header fetch dominates and you are better off batching by scene.
When the windows feed a larger tabular workflow rather than a per-patch computation, the same fan-out belongs at the task level instead — see Scaling with Dask-GeoPandas for the partitioned equivalent.
Verification
Confirm the window is the expected size and the patch is georeferenced where you asked — bracketing the reprojected AOI corners from step 2.
import rasterio
print("Patch shape:", aoi_patch.shape) # Patch shape: (148, 312)
assert aoi_patch.size > 0, "Empty window — bounds may miss the raster extent or wrong CRS"
with rasterio.open("aoi_patch.tif") as check:
left, bottom, right, top = check.bounds
print("Patch bounds (raster CRS):", round(left), round(bottom), round(right), round(top))
assert check.crs == rasterio.crs.CRS.from_epsg(32633)
# left/bottom/right/top should bracket (xmin, ymin, xmax, ymax) from step 2
The stronger check is on bytes, not shape: a read that returns the right pixels having transferred the whole file is still a failure. Turn on GDAL's curl tracing for one read and count the requests.
import rasterio
with rasterio.Env(CPL_CURL_VERBOSE="YES", CPL_DEBUG="ON", **gdal_opts):
with rasterio.open(cog_url) as src:
_ = src.read(1, window=aligned)
# stderr shows one ranged GET for the header and a small number of tile ranges.
# A single unbounded "GET /sentinel_ortho_cog.tif" with no Range header means
# the file is striped, or the server ignored Range and returned 200 instead of 206.
A 200 OK where you expected 206 Partial Content is the diagnostic for a server or CDN that has not been configured to honour range requests — the client is correct, the storage layer is not.
Edge Cases & Debugging
- "Windowed" read still slow. The file is not a real COG; validate with
rio cogeo validate ortho.tifand re-encode withrio cogeo create ortho.tif ortho_cog.tif. - Empty or tiny patch. Bounds are in the wrong CRS or fall outside the raster extent; reproject the AOI to
src.crsfirst (step 2) and confirm the numbers overlapsrc.bounds. - Patch georeferenced wrongly. You wrote it with the source transform instead of
src.window_transform(window)— the patch inherits the parent origin. - Edge windows clipped or negative offsets.
from_boundscan produce out-of-range offsets when the AOI runs past the raster edge; intersect withWindow(0, 0, src.width, src.height)or passboundless=Truewith afill_valuetoread(). - 403 or 404 on the URL. Credentials or headers are not set for a private bucket; configure
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY(orGDAL_HTTP_HEADERS) and use the/vsis3/path form for authenticated S3. - Axis-order surprise. Bounds come back transposed when
always_xy=Trueis omitted; PROJ 6+ honours the authority axis order (lat/lon for EPSG:4326) unless you force x/y — this and related traps are covered in Coordinate Reference System Transformations. WindowError: Bounds and transform are inconsistent. The AOI is fully outside the raster after reprojection, sofrom_boundsproduced a degenerate window; compare the reprojected corners againstsrc.boundsbefore reading.- Hundreds of tiny HTTP requests per window.
CPL_VSIL_CURL_CHUNK_SIZEis at its 16 KB default; raise it to 1 MB and setGDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES. - Reads corrupt or segfault under threads. A single dataset handle was shared across workers; open one per thread inside the worker function.
- A signed URL works, then 403s mid-job. The presigned expiry elapsed during a long read. Use
/vsis3/with real credentials for anything longer than a few minutes rather than refreshing signatures. - Overview reads look blocky at an unexpected level.
out_shapeselects the nearest pyramid level and GDAL resamples from it with nearest-neighbour by default; pass an explicitresampling=Resampling.bilineartoreadwhen the output is going to be displayed. - Windows through a
WarpedVRTare slower than expected. Reprojection happens on the fly per read, so a repeated grid of windows warps the same source tiles many times. Warp once to a local COG when the same area is read more than a handful of times.
Frequently Asked Questions
Is it ever faster to just download the whole COG? Yes, once your windows cover a large enough fraction of the file or you revisit it many times. The crossover is roughly when total windowed transfer approaches the file size: a few hundred scattered 512-pixel patches from a 40 GB scene is overwhelmingly a win for windowed reads, while a hundred windows that between them touch most tiles of a 200 MB scene is not. Latency counts too — a hundred sequential windows at 80 ms round trip each is eight seconds of pure waiting, which a single bulk download may beat.
Should I read in the raster's native CRS or reproject on the fly?
Read natively and reproject the small result. WarpedVRT can present the dataset in any CRS and lets you window in target coordinates, which is convenient, but the warp is recomputed on every read and it resamples pixels you may then resample again downstream. Native reads keep the pixel values untouched and defer exactly one resampling step to the end. Reserve on-the-fly warping for the case where many differently-projected sources must be read on a single common grid.
How do I pick the right overview level instead of hardcoding a divisor?
Derive it from the ratio between the window size and the pixels you actually need to display, then let out_shape do the selection: GDAL picks the closest level at or above your requested resolution. Hardcoding // 8 breaks when a scene ships a different overview factor list — check src.overviews(1) and note that a COG may have been written with factors [2, 4, 8, 16, 32] or with none at all, a choice made at encode time as covered in resampling and overviews when writing COGs.
Does this work against a plain web server, or do I need S3?
Any HTTP server that supports Range requests and reports Accept-Ranges: bytes works — /vsicurl/ cares about the protocol, not the vendor. What breaks it is a CDN or proxy configured to buffer and re-serve whole objects, which silently converts every ranged request into a full download. Verify with a curl -I for Accept-Ranges and a ranged curl -r 0-1023 that returns 206.
Why is my first window read slow and the rest fast?
The first read pays for the header and tile-offset table, which on a large multi-band COG can be several megabytes of TileOffsets/TileByteCounts, plus TLS handshake and DNS. Subsequent reads hit the /vsicurl/ cache for the header and reuse the connection. If every read is slow, the dataset handle is being reopened per read inside a loop — hoist the rasterio.open outside the loop when reads are sequential in one thread.
Can I compute area or distance from a windowed patch directly? Only if the raster is in a suitable projected CRS. Pixel dimensions come from the affine transform, so a patch from a geographic (degree-based) raster has cells whose ground size varies with latitude, and a Web Mercator raster has cells whose ground size varies even more. Reproject the patch — or better, read from a source already in a UTM zone or national grid, chosen the way Choosing a UTM Zone Automatically in Python describes — before turning pixel counts into square metres.