Prefix the key with /vsis3/ and open it as you would a local path. Inside a rasterio.Env carrying four settings β directory probing off, an allowed-extension list, HTTP/2 multiplexing and a virtual file system cache β a windowed read costs a handful of range requests instead of a full download. It builds on the Cloud Storage I/O for Spatial Batches guide, part of the broader Spatial Batch Processing & Async Workflows reference.
Prerequisites
- Python 3.10 or later
pip install "rasterio>=1.3"β the wheels include the S3 virtual file system driver- Credentials the process can find: an instance role, a profile, or the standard access-key variables
- A source object that is genuinely a COG; a striped GeoTIFF cannot be read partially at all
What Happens on rasterio.open
The open is not free, and knowing what it costs explains why some settings matter so much.
The red band is pure waste and is on by default. Turning it off is the single change that most improves cloud read performance, and it costs nothing because sidecar files are irrelevant for a self-contained COG.
Complete Working Implementation
# gistools/cloud_read.py
from __future__ import annotations
import rasterio
from rasterio.env import Env
from rasterio.warp import transform_bounds
from rasterio.windows import from_bounds
CLOUD_ENV = {
# Do not probe the "directory" for sidecar files on every open.
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
# Only consider these extensions when a probe does happen.
"CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif,.tiff,.ovr",
# Reuse connections and pipeline the small tile requests.
"GDAL_HTTP_MULTIPLEX": "YES",
"GDAL_HTTP_VERSION": "2",
# Keep fetched chunks around for the life of the Env block.
"VSI_CACHE": "TRUE",
"VSI_CACHE_SIZE": str(64 * 1024 * 1024),
# Bound the damage from a hung connection.
"GDAL_HTTP_TIMEOUT": "30",
"GDAL_HTTP_CONNECTTIMEOUT": "10",
}
def read_bbox(key: str, bbox, bbox_crs: str = "EPSG:4326"):
"""Read the pixels covering `bbox` from an S3-hosted COG.
`key` is a bucket-relative path such as "tiles/2026/08/scene.tif".
Returns (array, transform) for the window that was actually read.
"""
uri = f"/vsis3/{key}"
with Env(**CLOUD_ENV):
with rasterio.open(uri) as src:
left, bottom, right, top = transform_bounds(
bbox_crs, src.crs, *bbox, densify_pts=21)
window = from_bounds(left, bottom, right, top,
transform=src.transform)
window = window.round_offsets().round_lengths()
data = src.read(window=window, boundless=False)
return data, src.window_transform(window)
Step Annotations
densify_pts=21on the bounds transform. Reprojecting only the four corners of a bounding box understates the extent whenever the projection curves, and the resulting window clips the edges of what the caller asked for. Densifying the edges before transforming fixes it.round_offsets().round_lengths()before reading. A window with fractional offsets forces GDAL to resample; rounding to whole pixels keeps the read aligned and the values untouched.VSI_CACHE_SIZEinside theEnvblock, not globally. Scoping it means a long-running worker does not hold sixty-four megabytes of chunks between tasks.- Timeouts are set explicitly. GDALβs defaults are generous, and a hung connection in a batch of ten thousand tiles turns into a stalled worker that nothing notices.
boundless=Falseis the default and is deliberate here. A boundless read silently pads with nodata when the window extends past the raster, which hides a bounding box that does not actually intersect the scene.
One Named Gotcha: Credentials Resolve Differently for GDAL and boto3
A process where boto3 works and /vsis3/ returns 403 is common and confusing. GDAL does not use
boto3βs credential chain. It reads AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
AWS_SESSION_TOKEN, AWS_PROFILE and the instance metadata service β but the order and the
supported profile features differ, and it does not understand every arrangement an AWS config file
can express.
The reliable diagnosis is to ask GDAL what it resolved:
CPL_DEBUG=ON python -c "
import rasterio
from rasterio.env import Env
with Env(CPL_DEBUG='ON'):
rasterio.open('/vsis3/bucket/tile.tif')
" 2>&1 | grep -i 'aws\|credential\|region'
Two settings fix most cases: AWS_REGION set explicitly, because an unset region resolves to a
default that will not match your bucket; and AWS_S3_ENDPOINT when the store is S3-compatible rather
than S3 itself. For a requester-pays bucket, add AWS_REQUEST_PAYER=requester β its absence also
produces a bare 403.
The settings divide into two groups, and only one of them changes what the read costs.
Only the first three affect the bill. The last is the one that keeps a batch of ten thousand tiles from parking on a single unreachable object for the rest of the afternoon.
Verification
# 1. The read stayed partial.
CPL_CURL_VERBOSE=YES python -c "
from gistools.cloud_read import read_bbox
read_bbox('tiles/scene.tif', (-2.1, 51.3, -1.8, 51.6))
" 2>&1 | grep -c 'Range: bytes='
# 2. Nothing probed for sidecars.
CPL_CURL_VERBOSE=YES python -c "..." 2>&1 | grep -c '404' # expect 0
# 3. The source really is tiled β otherwise none of this helps.
python -c "
import rasterio
with rasterio.open('/vsis3/tiles/scene.tif') as s:
print('tiled:', s.profile.get('tiled'), 'blocks:', s.block_shapes[:1],
'overviews:', s.overviews(1))
"
A range count in single figures with no 404s is the healthy signature. An overview list that comes back empty means every read at reduced resolution will fetch full-resolution tiles, which is the other half of what makes a COG a COG.
When the Source Is Not a COG
Three properties make a GeoTIFF cloud-readable, and a file can miss any one of them while still opening perfectly well from local disk.
rio cogeo validate reports all three in one command, and running it over a sample of the inputs
before a large batch is the cheapest hour you will spend. A source failing any of them is worth
rewriting once rather than paying for on every read.
Reading Many Objects Efficiently
A single windowed read is the unit this guide covers; a batch reads thousands, and three things change at that scale.
Connection reuse becomes significant. Each rasterio.open on a new URL establishes a connection
unless the underlying HTTP layer keeps one alive, and TLS setup is a measurable fraction of a small
read. Keeping the reads inside one long-lived Env block, rather than opening and closing an
environment per object, lets GDAL reuse connections across them.
Per-prefix rate limits become visible. Object stores throttle per key prefix rather than per bucket,
so a batch hammering tiles/2026/08/ with fifty concurrent readers can see throttling responses
while the bucket as a whole is far from any limit. Spreading work across prefixes, or bounding
concurrency per prefix rather than globally, avoids it.
Header re-fetching becomes a real cost. Every open reads the tile directory, and for a batch that reads several windows from the same object that is a repeated expense. Opening once and reading several windows inside the same context is the fix, and it is worth restructuring a loop for.
# Instead of: for window in windows: with rasterio.open(uri) as src: ...
with Env(**CLOUD_ENV):
with rasterio.open(uri) as src:
for window in windows:
yield src.read(1, window=window)
That single change frequently halves the request count for a tiled workload, because the header is fetched once rather than once per window.
Overviews and Reduced-Resolution Reads
A read at reduced resolution should fetch a reduced-resolution level, and it only does so if the
object has overviews and the read asks for them. out_shape is what asks:
with rasterio.open(uri) as src:
thumb = src.read(1, out_shape=(1, 512, 512)) # picks a suitable overview
Without out_shape, a read of the full extent fetches every full-resolution tile and resamples in
memory β which for a large scene is a transfer of hundreds of megabytes to produce a thumbnail.
Checking that overviews exist is one line, and worth doing before a batch that will produce previews:
src.overviews(1) returns the decimation factors, and an empty list means every preview read will be
expensive. Building them is a one-off cost against a source that will be read many times, and it is
the other half of what makes a COG worth the name.
A final note on paths: keep the /vsis3/ prefix confined to the boundary that talks to GDAL. Passing
virtual file system paths through the rest of the pipeline means every function has to know about
them, and a path that works for rasterio will not work for pathlib, fsspec or a test fixture on
disk. Carrying a plain URI internally and translating at the point of use keeps everything else
portable, and makes the local-filesystem test path a one-line substitution rather than a special case.
A note on other stores
The same pattern applies to Google Cloud Storage through /vsigs/ and to Azure through /vsiaz/,
with the credential variables changing and everything else staying the same. For an S3-compatible
store, /vsis3/ works with AWS_S3_ENDPOINT set and AWS_VIRTUAL_HOSTING usually set to FALSE,
since most compatible stores use path-style addressing.
It is also worth setting GDAL_HTTP_MAX_RETRY and GDAL_HTTP_RETRY_DELAY explicitly rather than
inheriting whatever the build defaults to. GDAL retries some HTTP failures internally, and a batch
that also retries at the task level multiplies the two β five internal retries inside four task
retries is twenty attempts against an object that is not coming back.
Related
- Cloud Storage I/O for Spatial Batches β the access paths and their trade-offs.
- Tuning GDAL VSI Cache Settings for Cloud Reads β sizing the caches this read depends on.