How to Read a COG from a URL Without Downloading the Whole File
Problem statement
A Cloud-Optimised GeoTIFF can be read in place: rasterio.open("https://...") and a windowed read fetches only the tiles it needs. That works out of the box, and out of the box it is slower than it should be.
Measured against a byte-counting server, four scattered 512 Γ 512 window reads from one COG:
default settings 10 requests 4.610 MB
GDAL_DISABLE_READDIR_ON_OPEN 6 requests 4.155 MB
+ VSI_CACHE 7 requests 4.171 MB
Four of the ten requests were GDAL probing for sidecar files that do not exist. On a local network that is invisible; against an object store 100 ms away it is 400 ms added to every file you open.
Quick answer
import rasterio
from rasterio.windows import Window
SETTINGS = {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
"CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif,.TIF,.tiff",
"VSI_CACHE": "TRUE",
"VSI_CACHE_SIZE": "50000000",
"GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES",
}
with rasterio.Env(**SETTINGS):
with rasterio.open("https://example.com/scene.tif") as ds:
window = Window(1800, 1800, 512, 512)
data = ds.read(1, window=window)
Set the environment once, at the top of the process, rather than per call. Each rasterio.Env block re-applies the configuration and clears some caches.
Step-by-step solution
1. Turn off directory listing and sidecar probing
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR stops GDAL listing the "directory" containing the file, which on an object store is an expensive prefix query.
CPL_VSIL_CURL_ALLOWED_EXTENSIONS restricts which sidecar files GDAL will look for β .aux.xml, .tfw, .ovr, .msk. Each missing one is a round trip.
Together these removed four of ten requests in the benchmark above.
2. Read windows, not whole bands
window = rasterio.windows.from_bounds(*aoi_bounds, transform=ds.transform)
data = ds.read(1, window=window.round_offsets().round_lengths())
The saving is unbounded in the file size: a 512 Γ 512 window cost 1.59 MB from a 33 MB file, and would cost the same 1.59 MB from a 209 MB file.
3. Align windows to the tile grid
Tiles are compressed independently, so the smallest readable unit is a whole tile. Measured on a COG with 512-pixel tiles:
1 px 4 requests 470.8 kB
64 px 4 requests 470.8 kB
256 px 4 requests 470.8 kB
512 px 5 requests 1,585.3 kB
A single pixel costs a whole tile. If you need scattered values, read the tile block containing them once rather than issuing a read per point.
4. Use out_shape for downsampled reads
thumbnail = ds.read(1, out_shape=(ds.height // 16, ds.width // 16),
resampling=Resampling.average)
GDAL picks the appropriate overview level automatically. Measured: 1 request and 0.115 MB, against 24.672 MB from the same data without overviews.
Never read at full resolution and downsample in NumPy. That is the code path that transfers the whole file.
5. Reuse the dataset handle
Opening a remote dataset costs two requests and the header parse. In a loop over many windows, open once and read many times β the tile cache then works across reads.
Code examples
Example 1 β a session-level configuration
import os
import rasterio
REMOTE_SETTINGS = {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
"CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif,.TIF,.tiff,.vrt",
"VSI_CACHE": "TRUE",
"VSI_CACHE_SIZE": str(64 * 1024 * 1024),
"GDAL_CACHEMAX": 512, # MB of decoded block cache
"GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES",
"GDAL_HTTP_MULTIPLEX": "YES",
"GDAL_HTTP_MAX_RETRY": 3,
"GDAL_HTTP_RETRY_DELAY": 1,
"GDAL_BAND_BLOCK_CACHE": "HASHSET",
}
def configure_remote_reads():
"""Apply once per process, before any dataset is opened."""
for key, value in REMOTE_SETTINGS.items():
os.environ.setdefault(key, str(value))
print(f" configured {len(REMOTE_SETTINGS)} GDAL options for remote reads")
Setting these as process environment variables rather than inside rasterio.Env blocks means they apply to every read including those inside libraries you do not control β rioxarray, stackstac, rasterstats.
The retry settings matter in production: object stores return transient 500s and 503s, and without retries a batch job fails on one bad response out of ten thousand.
Example 2 β reading many windows efficiently
import numpy as np
import rasterio
from rasterio.windows import Window
def read_windows(url, windows, band=1):
"""One open, many reads, so the tile cache is shared."""
results = []
with rasterio.open(url) as ds:
block_h, block_w = ds.block_shapes[band - 1]
print(f" tiles are {block_w} x {block_h}")
# order windows by tile so nearby reads hit the cache
def tile_key(w):
return (int(w.row_off // block_h), int(w.col_off // block_w))
for window in sorted(windows, key=tile_key):
results.append(ds.read(band, window=window))
print(f" read {len(results)} windows from one open handle")
return results
def align_to_tiles(window, block_w, block_h):
"""Expand a window outward to whole tiles."""
col0 = int(window.col_off // block_w) * block_w
row0 = int(window.row_off // block_h) * block_h
col1 = int(np.ceil((window.col_off + window.width) / block_w)) * block_w
row1 = int(np.ceil((window.row_off + window.height) / block_h)) * block_h
return Window(col0, row0, col1 - col0, row1 - row0)
Sorting windows by tile is the cheapest optimisation available. Random-order reads evict the cache constantly; tile-ordered reads hit it repeatedly.
Example 3 β sampling points without a request per point
import numpy as np
import rasterio
from rasterio.windows import Window
def sample_points(url, xs, ys, band=1, pad=0):
"""Read one block covering all the points, then index it."""
with rasterio.open(url) as ds:
rows, cols = rasterio.transform.rowcol(ds.transform, xs, ys)
rows, cols = np.asarray(rows), np.asarray(cols)
row0, row1 = max(rows.min() - pad, 0), min(rows.max() + pad + 1, ds.height)
col0, col1 = max(cols.min() - pad, 0), min(cols.max() + pad + 1, ds.width)
window = Window(col0, row0, col1 - col0, row1 - row0)
span = (row1 - row0) * (col1 - col0)
print(f" {len(xs)} points span {col1 - col0} x {row1 - row0} px "
f"({span / 1e6:.1f} Mpx) β reading as one block")
if span > 50e6:
print(" ! the points are spread too widely for one block; "
"cluster them and read per cluster")
block = ds.read(band, window=window)
return block[rows - row0, cols - col0]
ds.sample() is convenient and issues a read per point. For a hundred points inside one tile that is a hundred fetches of the same 470 kB tile β the cache absorbs most of it, but the block read is unambiguous and faster.
Where points are spread across the raster, cluster them spatially and read one block per cluster.
Explanation
Why the sidecar probes cost so much
When GDAL opens a dataset it looks for auxiliary files: .aux.xml for statistics, .ovr for external overviews, .msk for masks, world files for georeferencing.
On a local filesystem these are cheap stat calls. Over HTTP each is a request, and a 404 costs a full round trip.
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR tells GDAL to assume the directory is empty, so it skips them. CPL_VSIL_CURL_ALLOWED_EXTENSIONS narrows which it will consider even when listing is allowed.
Measured: four requests removed from a ten-request read, before any data was transferred.
Why the tile is the unit
COG tiles are compressed independently. A client cannot decompress half a tile, so the minimum transfer for any pixel is that pixel's whole compressed tile.
That is why the benchmark shows 1 pixel, 64 Γ 64 and 256 Γ 256 all costing 470.8 kB β all three touch exactly one 512 Γ 512 tile plus the header.
The practical rule: make your access pattern tile-shaped. Sampling a raster at scattered points is close to the worst case; reading blocks is close to the best.
Why caching helps less than you expect
VSI_CACHE caches raw byte ranges; GDAL_CACHEMAX caches decoded blocks. Both help when the same tiles are read repeatedly.
In the benchmark, adding VSI_CACHE changed 6 requests to 7 and the bytes barely moved β because each of the four windows touched different tiles, so there was nothing to reuse.
Caching pays when access is repetitive or clustered: a tile server rendering adjacent tiles, a time series reading the same window from many files, a zoom-out followed by a zoom-in. Ordering reads to create that repetition is worth more than increasing the cache size.
Why to open once
Opening a remote dataset costs two requests and a header parse. Inside a loop that is two requests per iteration for nothing.
Worse, a fresh open resets the block cache, so windows that would have hit it miss instead. Keeping the handle open across a loop is often a larger saving than any environment setting.
Edge cases or notes
- Set the environment once per process, not per read.
GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIRis the single highest-value setting.- Use
out_shapefor downsampled reads, never full-resolution plus NumPy. - A 1-pixel read costs a whole tile β 470 kB here.
- Sort windows by tile so the cache is used.
- Open once, read many. A fresh open costs two requests and clears the cache.
- Set retries. Object stores return transient errors, and one 503 should not fail a batch.
- Requester-pays buckets need credentials and
AWS_REQUEST_PAYER=requester.
Internal links
- Cloud-optimised GeoTIFF explained β why the tile is the unit
- Reading a COG from a URL is slow or downloads everything β diagnosing a bad read
- Cloud-native geospatial explained β the wider pattern
- How to read spatial data from S3 and other object storage β credentials and requester-pays
- GDAL cannot open an S3 or HTTPS path β when the open itself fails
- How to load Sentinel-2 bands into Python as an analysis-ready array β windowed reads in a real pipeline
- How to choose chunk and tile sizes that actually help β matching access to layout
- How to extract raster values at point locations with rasterio β the point-sampling case
FAQ
How do I read a COG from a URL in Python?
rasterio.open("https://...") and read a window. Set GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR first β it removed four of ten requests in a benchmark here.
How much data does a windowed read transfer?
Only the tiles the window touches. A 512 Γ 512 window cost 1.59 MB from a 33 MB file, and would cost the same from a much larger one.
Why does reading a single pixel take so long?
Because tiles are compressed independently, so the smallest transfer is one whole tile β 470 kB on a 512-tile COG.
How do I read a thumbnail cheaply?
ds.read(1, out_shape=(h // 16, w // 16), resampling=Resampling.average). GDAL selects the right overview: 1 request and 0.115 MB, against 24.7 MB without overviews.
Should I set VSI_CACHE?
Yes, but expect little from it unless your reads are clustered or repetitive. In a four-scattered-window benchmark it changed nothing.
Why open the file once?
A fresh open costs two requests and clears the block cache. In a loop over windows, opening once is often the largest single saving.
What about authentication?
For S3, set credentials in the environment and AWS_REQUEST_PAYER=requester for requester-pays buckets. Public HTTPS needs nothing.