Diagnosing Slow COG Reads with GDAL VSI Logging

Turn on GDAL’s HTTP logging for one read and count what it actually does:

import rasterio

with rasterio.Env(CPL_DEBUG="ON", CPL_CURL_VERBOSE="YES"):
    with rasterio.open("https://example-bucket.s3.amazonaws.com/scene.tif") as src:
        src.read(1, window=((0, 512), (0, 512)))
# stderr now lists every request: listings, header reads, tile ranges

Nearly every slow remote read is explained by the log in under a minute: too many requests, requests of the wrong kind, or requests that should not have happened at all. This page belongs to understanding Cloud-Optimized GeoTIFF structure in Core Raster Fundamentals & STAC Mapping.


What a Healthy Read Looks Like

Healthy and unhealthy request timelines A healthy read makes one request for the header and one merged range request for the tiles it needs. An unhealthy read first lists the bucket prefix looking for sidecars, then makes several small header requests because the header exceeds the initial fetch, then one request per tile. The unhealthy read takes about ten times longer for the same pixels. Same 512 px window, two request patterns healthy hdr tiles 2 requests, ~80 ms unhealthy list prefix 8 requests, ~900 ms directory listing header reads tile data Every class of wasted request has a specific configuration fix — the log tells you which.

The healthy pattern is the one the COG format was designed to allow: the header lives at the start of the file and fits in one fetch, and the tiles a window needs are near each other and can be fetched in one multi-range request. Anything beyond that is either configuration or file layout.


Environment & Setup

Package Version pin Used for
rasterio >=1.3.0 Scoped GDAL configuration with rasterio.Env
GDAL >=3.6 VSI curl driver and its debug output
rio-cogeo >=5.0 Checking whether the file layout is the cause
pip install "rasterio>=1.3.0" "rio-cogeo>=5.0"

Complete Working Example

import contextlib
import io
import os
import re
import sys
import time

import rasterio


@contextlib.contextmanager
def capture_stderr_fd():
    """Capture C-level stderr, where GDAL writes its debug output."""
    r, w = os.pipe()
    saved = os.dup(2)
    os.dup2(w, 2)
    buf = io.BytesIO()
    try:
        yield buf
    finally:
        os.dup2(saved, 2)
        os.close(w)
        with os.fdopen(r, "rb") as fh:
            buf.write(fh.read())


def diagnose(url: str, window=((0, 512), (0, 512)), **gdal_opts) -> dict:
    opts = {"CPL_DEBUG": "ON", "CPL_CURL_VERBOSE": "YES"} | gdal_opts
    with capture_stderr_fd() as buf:
        t0 = time.perf_counter()
        with rasterio.Env(**opts):
            with rasterio.open(url) as src:
                src.read(1, window=window)
        elapsed = time.perf_counter() - t0
    log = buf.getvalue().decode(errors="ignore")

    ranges = re.findall(r"Range: bytes=([\d\-,]+)", log)
    return {
        "seconds": round(elapsed, 3),
        "http_requests": len(re.findall(r"^> (GET|HEAD) ", log, re.M)),
        "range_requests": len(ranges),
        "listings": len(re.findall(r"list-type=2|ListBucket|delimiter=", log)),
        "sidecar_probes": len(re.findall(r"\.(ovr|aux\.xml|msk)\b", log)),
    }


if __name__ == "__main__":
    url = "https://example-bucket.s3.amazonaws.com/products/ndvi.tif"
    print("default :", diagnose(url))
    print("tuned   :", diagnose(url, GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
                                CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif",
                                GDAL_INGESTED_BYTES_AT_OPEN="32768",
                                GDAL_HTTP_MERGE_CONSECUTIVE_RANGES="YES"))

Capturing the C-level file descriptor rather than Python’s sys.stderr is necessary because GDAL writes its debug output directly from C and bypasses Python’s stream objects entirely. Running the same read with and without the tuning options side by side turns a vague “it is slow” into a table of which requests disappeared.


Mapping Log Symptoms to Fixes

Symptom, cause, setting Directory listings on open are removed by disabling readdir on open. Probes for overview and mask sidecars are removed by restricting allowed extensions. Several small header requests are removed by ingesting more bytes at open. Many adjacent tile requests are merged by enabling consecutive range merging. A single tile request per block that cannot be merged points at the file layout rather than the configuration. Read the log, apply the matching fix symptom in the log setting list-type=2 / ListBucket GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR requests for .ovr, .msk, .aux.xml CPL_VSIL_CURL_ALLOWED_EXTENSIONS=.tif several small header ranges GDAL_INGESTED_BYTES_AT_OPEN=32768 many adjacent tile ranges GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES If tile ranges are scattered across the file and cannot merge, the file is not a proper COG.

The last row in that table is the case configuration cannot fix. If the tile requests for a small window are scattered across the whole file, the internal layout is not cloud-optimized — the tiles are not in the order the format expects, or the file is striped rather than tiled. rio cogeo validate will say so, and the fix is to rewrite the file with the tooling in converting a GeoTIFF to a COG with rio-cogeo.


Setting the Fixes Once

Once diagnosed, the fixes belong in the environment of every process that reads the archive, not scattered through code. For a batch job that means the container image; for a tile server, its deployment environment; for a notebook, a rasterio.Env block at the top. The set shown above — disable directory listing, restrict extensions, ingest a larger header, merge consecutive ranges — is safe for essentially every COG archive and worth making the default everywhere.

Two more settings help specific workloads. VSI_CACHE=TRUE with a generous VSI_CACHE_SIZE keeps recently read ranges in memory, which matters when the same file is read repeatedly in small windows. GDAL_HTTP_MULTIPLEX=YES lets several range requests share one HTTP/2 connection, which matters for concurrent reads against one host. The container-level configuration pattern is set out in caching PROJ data and GDAL config in containers.


Verification

Prove the fix with the same measurement Run the identical diagnostic read before and after applying the configuration. The request count should drop to two or three for a small window and the elapsed time by a similar factor. A fix that does not change the count did not address the dominant cause. Requests for one 512 px window before 14 requests · 1.4 s after 2 requests · 0.09 s Same file, same window, same network — only the configuration changed.
before = diagnose(url)
after = diagnose(url, GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
                 CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif",
                 GDAL_HTTP_MERGE_CONSECUTIVE_RANGES="YES")
print(before, after, sep="\n")
assert after["listings"] == 0
assert after["http_requests"] <= 4, "still too many requests — check the file layout"

Measuring the same read before and after is the only way to know the change helped; anecdotal “it feels faster” is unreliable against the natural variance of network latency. Run each measurement a few times and compare medians when the difference is small.


Common Errors

No debug output appears

It went to C-level stderr, which Python redirection does not capture. Capture the file descriptor as in the example, or run the script from a shell and redirect 2>.

Requests are fine but reads are still slow

Latency, not request count, dominates: the compute is far from the bucket. Run the read in the storage region.

Every tile is a separate request that cannot merge

The file is not a proper COG. Validate it and rewrite if needed.

Logging makes the job much slower

Verbose output is expensive. Enable it for one diagnostic read only.

The first read is slow and later ones are fast

That is expected: the first open fetches the header and the later ones hit GDAL’s cache. Diagnose the first read specifically, since it is what every new worker and every cold tile request pays, and raise GDAL_INGESTED_BYTES_AT_OPEN if the header needs several round trips.

Reads slow down when many run concurrently

Each process opens its own connections and repeats its own listings. Share the fixes through the environment rather than per-script, and enable HTTP multiplexing so concurrent reads from one process reuse connections.


Frequently Asked Questions

Q: How many requests should a small COG window read take? One or two for the header on first open, then roughly one per internal tile the window touches — often merged into a single multi-range request. A small window read costing dozens of requests is a configuration or file-layout problem, not a network problem.

Q: What is the most common cause of slow opens? GDAL listing the directory to look for sidecar files. On object storage a listing can take seconds and runs on every open. Setting GDAL_DISABLE_READDIR_ON_OPEN to EMPTY_DIR removes it.

Q: Should verbose logging stay on in production? No. It prints every HTTP exchange and slows reads measurably. Turn it on for a single diagnostic read, capture the output, and turn it off again.

Q: Does the same approach work for Azure and GCS? Yes. The VSI drivers for Azure Blob and Google Cloud Storage use the same curl layer and emit the same debug output, and the listing and extension settings apply identically.