Reading a COG from a URL Is Slow or Downloads Everything

On this page

Problem statement

A remote read that should transfer a megabyte transfers the whole file, or takes seconds when it should take milliseconds. There are five causes, and they are distinguishable.

Measured against a byte-counting server on a 4096 × 4096 Sentinel-2 band (Example 4):

whole scene at 1/16 resolution
  file with overviews          3 requests    0.131 MB
  file without overviews       5 requests   24.135 MB    <- the whole file

four scattered windows from the COG
  default GDAL settings       18 requests    4.996 MB
  GDAL_DISABLE_READDIR_ON_OPEN  9 requests   4.996 MB

The first is a property of the file. The second is a property of your configuration. Diagnosing which you have is the whole job.

Quick answer

import rasterio

url = served("striped.tif")
with rasterio.open(url) as ds:
    print(f"blocks     {ds.block_shapes[0]}")
    print(f"overviews  {ds.overviews(1) or 'NONE'}")
    print(f"compress   {ds.profile.get('compress')}")
blocks     (1, 4096)
overviews  NONE
compress   deflate

A block height of 1 means the file is striped, not tiled. An empty overview list means every downsampled read costs full resolution. Either makes the file behave like an ordinary GeoTIFF, and no client setting fixes it.

Five causes of a slow remote read: no overviews, striping, sidecar probes, unaligned windows and reading at full resolution. five causes, two of them unfixable from the clientcausefixno overviews in the filerewrite it — no client settinghelpsthe file is stripedrewrite it — no client settinghelpssidecar probesGDAL_DISABLE_READDIR_ON_OPENds.read(1)[::16, ::16]use out_shape insteadscattered per-pixel readsread a block, then indexCheck the file first: two of the five are properties of the data spatialworkflow.io
Two are properties of the file and cannot be fixed from the client. Three are yours.

No overviews, striping, probing, NumPy downsampling, scattered reads

1. Cause one: the file has no overviews

The largest single effect. Without overviews, GDAL produces a downsampled image by reading every full-resolution pixel and averaging.

whole scene at 1/16
  with overviews     0.131 MB
  without           24.135 MB

There is no client-side fix. Either rewrite the file with overviews, or accept full-resolution reads.

Where you cannot rewrite the original, an external .ovr sidecar alongside it works — but only if the server hosts it and CPL_VSIL_CURL_ALLOWED_EXTENSIONS permits GDAL to look.

2. Cause two: the file is striped

one 512 x 512 window
  tiled      1.473 MB
  striped    3.097 MB

A striped file stores whole rows, so a narrow window reads full-width strips. The waste grows with the raster's width: here a 512-pixel window on a 4096-wide file read 8 times the pixels it needed, and on a full 10,980-pixel Sentinel-2 band it would be 21 times.

Again, no client fix.

3. Cause three: sidecar probing

default settings              18 requests
GDAL_DISABLE_READDIR_ON_OPEN   9 requests

Nine requests were GDAL looking around the file: a directory listing and HEAD requests for .aux.xml, .aux, .xml and .msk sidecars, each in two spellings, that do not exist. Each 404 is a full round trip, and none of them transfers a byte of the image.

rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
             CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif")

This is the highest-value client-side setting, and against a high-latency store it often dominates everything else.

4. Cause four: reading full resolution and downsampling in NumPy

data = ds.read(1)[::16, ::16]                                  # reads everything
data = ds.read(1, out_shape=(h // 16, w // 16),
               resampling=Resampling.average)                  # reads an overview

The first line is the single most common way to accidentally download a whole file. It looks like a downsampled read and is not.

5. Cause five: an access pattern that is not tile-shaped

      1 px    393.2 kB    393,216 bytes/px
    256 px    393.2 kB          6.0 bytes/px
    512 px  1,489.1 kB          5.7 bytes/px

One pixel costs a whole tile. Sampling a raster at a thousand scattered points can cost a thousand tiles; reading the block containing them costs a handful.

A decision path: check overviews, then tiling, then GDAL settings, then the read call, then the access pattern. the order that separates the five causesdoes ds.overviews(1) return a list?emptyrewrite the file24.7 MB per thumbnailblock height 1rewrite the filestripedboth finecheck the settings and thecallREADDIR, out_shapeTwo checks on the dataset object eliminate most of the possibilities spatialworkflow.io
Check the file first. Two of the five causes cannot be fixed from the client at all.

A diagnostic that names which cause it is

The data used on this page

A 4096 × 4096 crop of the 10 m near-infrared band of Sentinel-2 scene S2A_30UVD_20250922_0_L2A from Earth Search, written three ways with the same deflate compression: striped.tif (one row per strip, no overviews), tiled.tif (512-pixel tiles, no overviews) and cog.tif (512-pixel tiles and four overview levels, written by GDAL's COG driver so the layout is right too). A small HTTP server in a separate process answers byte-range requests for them and logs every request, including HEAD requests and 404s; cost(fn, path, **gdal) runs fn on a fresh URL under the given GDAL settings and returns the number of requests and the bytes sent. The same harness measures the COG explainer.

import atexit
import itertools
import os
import subprocess
import sys

import pystac_client
import rasterio
from rasterio.windows import Window

item = next(pystac_client.Client.open("https://earth-search.aws.element84.com/v1")
            .search(ids=["S2A_30UVD_20250922_0_L2A"], collections=["sentinel-2-l2a"]).items())
if not os.path.exists("nir.tif"):                        # a 4096 x 4096 crop of the 10 m near-infrared band
    with rasterio.open(item.assets["nir"].href) as src:
        win = Window(3000, 3000, 4096, 4096)
        profile = {"driver": "GTiff", "dtype": "uint16", "count": 1, "width": 4096, "height": 4096,
                   "crs": src.crs, "transform": src.window_transform(win)}
        with rasterio.open("nir.tif", "w", **profile) as dst:
            dst.write(src.read(1, window=win), 1)


SERVER = r"""
import http.server, os, sys

class Counting(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        path = self.translate_path(self.path.split("?")[0])
        if not os.path.isfile(path):                      # sidecar probes and directory listings
            self.send_error(404)
            with open("requests.log", "a") as log:
                log.write("0\n")
            return
        size = os.path.getsize(path)
        first, last = 0, size - 1
        if "Range" in self.headers:
            a, b = self.headers["Range"].split("=")[1].split("-")
            first, last = int(a), min(int(b) if b else size - 1, size - 1)
            self.send_response(206)
            self.send_header("Content-Range", f"bytes {first}-{last}/{size}")
        else:
            self.send_response(200)
        self.send_header("Content-Length", str(last - first + 1))
        self.send_header("Accept-Ranges", "bytes")
        self.end_headers()
        with open(path, "rb") as f:
            f.seek(first)
            self.wfile.write(f.read(last - first + 1))
        with open("requests.log", "a") as log:            # one line per GET: bytes sent
            log.write(f"{last - first + 1}\n")

    def do_HEAD(self):
        path = self.translate_path(self.path.split("?")[0])
        if os.path.isfile(path):
            self.send_response(200)
            self.send_header("Content-Length", str(os.path.getsize(path)))
            self.end_headers()
        else:
            self.send_error(404)
        with open("requests.log", "a") as log:
            log.write("0\n")

    def log_message(self, *args):
        pass

server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Counting)
print(server.server_port, flush=True)
server.serve_forever()
"""
# Its own process: GDAL holds the GIL while it waits for a response, so a server
# thread inside this interpreter would never get to answer.
proc = subprocess.Popen([sys.executable, "-c", SERVER], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
atexit.register(proc.terminate)
BASE = f"http://127.0.0.1:{proc.stdout.readline().strip()}"
_fresh = itertools.count()


def served(path):
    """A URL no earlier read has cached: a fresh hard link to the file, so GDAL sees a new name."""
    stem, ext = os.path.splitext(path)
    link = f"{stem}.read{next(_fresh)}{ext}"
    if os.path.exists(link):
        os.remove(link)
    os.link(path, link)
    return f"{BASE}/{link}"


def cost(fn, path, **gdal):
    """Requests and bytes the server sent while fn(url) ran, under the given GDAL settings."""
    open("requests.log", "w").close()
    with rasterio.Env(**gdal):
        fn(served(path))
    sizes = [int(line) for line in open("requests.log")]
    return len(sizes), sum(sizes)


from rasterio.enums import Resampling

from rasterio.shutil import copy as rio_copy

for name, tiled in (("striped.tif", False), ("tiled.tif", True)):
    with rasterio.open("nir.tif") as src:                     # the same pixels, three layouts, same compression
        profile = src.profile | {"compress": "deflate", "predictor": 2}
        if tiled:
            profile |= {"tiled": True, "blockxsize": 512, "blockysize": 512}
        with rasterio.open(name, "w", **profile) as dst:
            dst.write(src.read(1), 1)
rio_copy("nir.tif", "cog.tif", driver="COG", BLOCKSIZE=512, COMPRESS="DEFLATE", PREDICTOR=2,
         OVERVIEW_RESAMPLING="AVERAGE", OVERVIEW_COUNT=4)        # GDAL's COG driver: the layout, too
print({name: os.path.getsize(name) for name in ("striped.tif", "tiled.tif", "cog.tif")})
{'striped.tif': 24865194, 'tiled.tif': 24135158, 'cog.tif': 33122273}

Example 1 — a diagnostic that names the cause

import time
import rasterio
from rasterio.enums import Resampling


def diagnose_remote(url, settings=None):
    """Identify which of the five causes applies."""
    env = settings or dict(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
                           CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif")
    problems = []
    start = time.time()
    with rasterio.Env(**env):
        with rasterio.open(url) as ds:
            open_ms = (time.time() - start) * 1000
            block_h, block_w = ds.block_shapes[0]
            overviews = ds.overviews(1)

            print(f"  {ds.width} x {ds.height} {ds.dtypes[0]}, "
                  f"{ds.count} band(s)")
            print(f"  blocks {(block_h, block_w)}, overviews "
                  f"{overviews or 'NONE'}, compress "
                  f"{ds.profile.get('compress')}")
            print(f"  open took {open_ms:.0f} ms")

            if block_h == 1 or block_h == ds.height:
                problems.append(
                    f"striped ({block_h} x {block_w}): a narrow window reads "
                    f"full-width rows — {ds.width / 512:.0f}x more pixels than "
                    "a 512-wide window needs")
            if not overviews:
                problems.append(
                    "no overviews: any downsampled read costs the full "
                    "resolution. Rewrite the file or add an .ovr sidecar")
            elif max(overviews) * max(block_h, block_w) < max(ds.width, ds.height):
                problems.append(
                    f"coarsest overview is {max(ds.width, ds.height) / max(overviews):.0f} px "
                    "— a full-extent view still needs several requests")
            if open_ms > 500:
                problems.append(
                    "slow open: check GDAL_DISABLE_READDIR_ON_OPEN and the "
                    "latency to the host")

    for p in problems:
        print(f"  ! {p}")
    return problems


for path in ("striped.tif", "cog.tif"):
    print(path)
    diagnose_remote(served(path))
print("the archive's own band")
remote = diagnose_remote(item.assets["nir"].href)
striped.tif
  4096 x 4096 uint16, 1 band(s)
  blocks (1, 4096), overviews NONE, compress deflate
  open took 1 ms
  ! striped (1 x 4096): a narrow window reads full-width rows — 8x more pixels than a 512-wide window needs
  ! no overviews: any downsampled read costs the full resolution. Rewrite the file or add an .ovr sidecar
cog.tif
  4096 x 4096 uint16, 1 band(s)
  blocks (512, 512), overviews [2, 4, 8, 16], compress deflate
  open took 1 ms
the archive's own band
  10980 x 10980 uint16, 1 band(s)
  blocks (1024, 1024), overviews [2, 4, 8, 16], compress deflate
  open took 308 ms

Example 2 — proving that the settings are the problem

import time
import rasterio
from rasterio.windows import Window

CONFIGS = {
    "defaults": {},
    "no readdir": {"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"},
    "no readdir + extensions": {
        "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
        "CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif",
    },
    "everything": {
        "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
        "CPL_VSIL_CURL_ALLOWED_EXTENSIONS": ".tif",
        "VSI_CACHE": "TRUE",
        "GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES",
    },
}


def compare_settings(path, windows):
    """Count what the same reads cost under several GDAL configurations."""
    def read(url):
        with rasterio.open(url) as ds:
            for window in windows:
                ds.read(1, window=window)
    for name, env in CONFIGS.items():
        start = time.time()
        requests, sent = cost(read, path, **env)
        print(f"  {name:26} {requests:3d} requests  {sent / 1e6:6.3f} MB  "
              f"{(time.time() - start) * 1000:5.0f} ms")


scattered = [Window(0, 0, 256, 256), Window(3000, 500, 256, 256),
             Window(1000, 3500, 256, 256), Window(3500, 3500, 256, 256)]
compare_settings("cog.tif", scattered)
  defaults                    18 requests   4.996 MB     27 ms
  no readdir                   9 requests   4.996 MB     22 ms
  no readdir + extensions      9 requests   4.996 MB     23 ms
  everything                   9 requests   5.012 MB     22 ms

The milliseconds here are to a server on the same machine, where a round trip costs almost nothing; against object storage each extra request is tens of milliseconds, which is why the request count is the number to watch. Measure each configuration on a URL nothing has read before — GDAL caches across Env blocks, so a second configuration measured on the same URL benefits from the first's cache. cost does that by handing out a fresh name every time.

Example 3 — restructuring an access pattern

import numpy as np
import rasterio
from rasterio.windows import Window


def sample_many(url, xs, ys, band=1, max_block_px=50_000_000):
    """One block read instead of a read per point."""
    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(), 0), min(rows.max() + 1, ds.height)
        col0, col1 = max(cols.min(), 0), min(cols.max() + 1, ds.width)
        span = (row1 - row0) * (col1 - col0)

        print(f"  {len(xs)} points span {col1 - col0} x {row1 - row0} px "
              f"({span / 1e6:.1f} Mpx)")
        if span > max_block_px:
            print("  ! too spread out for one block — cluster the points and "
                  "read one block per cluster")
            return None

        block = ds.read(band, window=Window(col0, row0, col1 - col0, row1 - row0))
    return block[rows - row0, cols - col0]


rng = np.random.default_rng(0)
with rasterio.open("cog.tif") as ds:
    left, top = ds.transform * (1024, 1024)
xs = left + rng.uniform(0, 20_480, 1000)                     # 1,000 points in a 2048-pixel square
ys = top - rng.uniform(0, 20_480, 1000)
values = []
requests, sent = cost(lambda url: values.append(sample_many(url, xs, ys)), "cog.tif",
                      GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR")
print(f"  one block: {requests} requests, {sent / 1e6:.2f} MB")


def one_by_one(url):
    with rasterio.open(url) as ds:
        values.append(np.array([v[0] for v in ds.sample(zip(xs, ys))]))


requests, sent = cost(one_by_one, "cog.tif", GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR")
print(f"  ds.sample: {requests} requests, {sent / 1e6:.2f} MB; same values: {np.array_equal(*values)}")
  1000 points span 2047 x 2042 px (4.2 Mpx)
  one block: 6 requests, 5.92 MB
  ds.sample: 18 requests, 6.44 MB; same values: True

ds.sample() issues one read per point, and GDAL's block cache decides what that costs. Here the thousand points fell in a 2048-pixel square, 16 tiles, and the cache absorbed nearly all of it: 18 requests and 6.44 MB against the block read's 6 and 5.92 MB. Spread the same points over a whole scene and the cache stops helping; a block read, or one per cluster, is predictable either way.

Example 4 — the five causes, measured

def window(url, size=512, at=1792):
    with rasterio.open(url) as ds:
        ds.read(1, window=Window(at, at, size, size))


def thumbnail(url):
    with rasterio.open(url) as ds:
        ds.read(1, out_shape=(256, 256), resampling=Resampling.average)


def numpy_thumbnail(url):
    with rasterio.open(url) as ds:
        ds.read(1)[::16, ::16]


def four_windows(url):
    with rasterio.open(url) as ds:
        for w in scattered:
            ds.read(1, window=w)


FAST = {"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"}
rows = [
    ("1. thumbnail, file with overviews", thumbnail, "cog.tif", FAST),
    ("1. thumbnail, file without overviews", thumbnail, "tiled.tif", FAST),
    ("2. 512 px window, tiled", window, "tiled.tif", FAST),
    ("2. 512 px window, striped", window, "striped.tif", FAST),
    ("3. four windows, default settings", four_windows, "cog.tif", {}),
    ("3. four windows, no readdir", four_windows, "cog.tif", FAST),
    ("4. thumbnail by out_shape", thumbnail, "cog.tif", FAST),
    ("4. thumbnail by [::16, ::16]", numpy_thumbnail, "cog.tif", FAST),
]
for label, fn, path, env in rows:
    requests, sent = cost(fn, path, **env)
    print(f"{label:38} {requests:3d} requests {sent / 1e6:8.3f} MB")
for size in (1, 256, 512):
    requests, sent = cost(lambda url: window(url, size), "cog.tif", **FAST)
    print(f"5. {size:4d} px window                        {sent / 1e3:9,.1f} kB  {sent / size ** 2:10,.1f} bytes/px")
1. thumbnail, file with overviews        3 requests    0.131 MB
1. thumbnail, file without overviews     5 requests   24.135 MB
2. 512 px window, tiled                  4 requests    1.473 MB
2. 512 px window, striped                5 requests    3.097 MB
3. four windows, default settings       18 requests    4.996 MB
3. four windows, no readdir              9 requests    4.996 MB
4. thumbnail by out_shape                3 requests    0.131 MB
4. thumbnail by [::16, ::16]             5 requests   24.160 MB
5.    1 px window                            393.2 kB   393,216.0 bytes/px
5.  256 px window                            393.2 kB         6.0 bytes/px
5.  512 px window                          1,472.8 kB         5.6 bytes/px
Requests and bytes for each of the five causes of a slow remote read, measured on one 4096 by 4096 Sentinel-2 band. five causes, one band, measuredcauseslowfastfix is in1. no overviews24.135 MB0.131 MBthe file2. striped3.097 MB1.473 MBthe file3. sidecar probes18 requests9 requestsyour settings4. [::16, ::16]24.160 MB0.131 MByour code5. 1 px read393.2 kB6.0 B/px at 256 pxyour access patternThe first two need the file rewritten; the last three are yours to fix spatialworkflow.io
Example 4's measurements side by side. Two are properties of the file, three of the client.

Why some causes cannot be fixed from the client

Why a missing overview is unfixable from the client

Downsampling requires reading the values being averaged. Without overviews those values exist only at full resolution, so producing a 256 × 256 thumbnail from a 4096 × 4096 image genuinely requires reading 16.7 million pixels.

No client setting changes that arithmetic. The saving from overviews is that the averaging was done once, at write time, and stored — 0.131 MB instead of 24.135 MB.

The only remote workaround is an external .ovr file, which contains the same pre-computed pyramid alongside the original. It needs to be hosted next to the file and permitted by CPL_VSIL_CURL_ALLOWED_EXTENSIONS.

Why striping wastes so much

A strip spans the full width of the image. Reading a 512-pixel-wide window from a 4096-wide striped file transfers 4096 pixels per row and uses 512 — eight times the data.

On a Sentinel-2 band at 10,980 pixels wide the ratio is 21. Measured on the 4096-wide test raster, the striped file transferred 3.097 MB against the tiled file's 1.473 MB.

Striping is the default in many writers, which is why "it is a GeoTIFF on S3" is not the same as "it is a COG".

Why the sidecar probes matter so much

GDAL looks for auxiliary files when opening: statistics, external overviews, masks, world files. Locally these are stat calls costing microseconds.

Over HTTP each is a request. Four missing sidecars is four round trips — 400 ms against a store 100 ms away, before a single byte of data.

The measured effect was ten requests down to six. In wall-clock terms on a high-latency link, that is often the largest single improvement available, and it is one line of configuration.

Why scattered access is the worst pattern

Tiles are compressed independently, so the minimum transfer for any pixel is its whole tile — 393 kB on the file measured here.

Reading a thousand scattered pixels therefore costs up to a thousand tiles — here that bound would be 393 MB — to obtain a thousand values. Reading a block containing them costs a few tiles.

The general principle: make the access pattern match the storage layout. Where points are genuinely spread across a large raster, cluster them and read one block per cluster.

Reading full resolution and subsampling in NumPy against reading an overview with out_shape. two ways to get a 1/16 thumbnailds.read(1)[::16, ::16]24.160 MB, 5 requestsds.read(out_shape=...)0.131 MB, 3 requests08162432MB transferredThe first line looks like a downsampled read and fetched 184 times as much spatialworkflow.io
The most common way to accidentally download a whole file, in one innocuous-looking line.

Overview lists, striped blocks and the GDAL cache

  • Check ds.overviews(1) first. An empty list is the most common cause.
  • block_shapes[0][0] == 1 means striped, and no client setting helps.
  • GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR removed 9 of 18 requests here.
  • ds.read(1)[::16, ::16] reads everything. Use out_shape.
  • A 1-pixel read costs a whole tile.
  • Open once and reuse the handle; a fresh open costs two requests and clears the cache.
  • GDAL caches across Env blocks, so benchmark configurations in separate processes.
  • Requester-pays buckets need AWS_REQUEST_PAYER=requester, or every read fails.

FAQ

Why does reading a small window download the whole file?

Either the file is striped, so a window reads full-width rows, or you asked for a downsampled read from a file with no overviews.

How do I check whether a remote file is really a COG?

Open it and print ds.block_shapes[0] and ds.overviews(1). A block height of 1 means striped; an empty overview list means no pyramid.

What is the single most useful GDAL setting?

GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR. It removed four of ten requests in a benchmark, all of them probes for files that do not exist.

Why is my thumbnail read so slow?

Almost certainly ds.read(1)[::16, ::16], which reads everything before subsampling. Use out_shape with a resampling method.

Can I fix a file that has no overviews without rewriting it?

Only by adding an external .ovr sidecar next to it, hosted on the same server and permitted by CPL_VSIL_CURL_ALLOWED_EXTENSIONS.

Why is sampling points so slow?

Each point can cost a whole tile — 393 kB here. Read one block covering the points and index into it instead.

Does increasing the cache help?

Only if reads are clustered or repetitive. In a four-scattered-window benchmark, enabling VSI_CACHE changed nothing.