Validate whether a GeoTIFF is a proper Cloud-Optimized GeoTIFF, and explain exactly why it is not.
"Is this a COG?" is a yes/no question with an unhelpful answer. When a raster is slow to read from S3, what you actually need to know is which structural property is missing and what it costs you: a stripped file forces a full-width read for every small window; a file with no overviews forces full-resolution decoding for every zoomed-out tile request; a file whose overviews were added in place has its IFDs appended after the pixel data, so a client must seek to the end of a multi-gigabyte object before it can read a single pixel.
That last case is the nasty one. GDAL will happily open the file, rio cogeo validate may or may
not mention it, and everything works fine on local disk. Over HTTP it doubles your request count on
every single open.
cog-inspector reports every structural property as PASS / FAIL / WARN with the reason, separates
hard COG-layout violations from performance advice, estimates the HTTP range-request cost of opening
the file, and prints a gdal_translate command whose flags are derived from what actually failed.
Python 3.12 or newer. Clone the repository and install the dependencies:
git clone https://github.com/python-remote-sensing/cog-inspector.git
cd cog-inspector
pip install -r requirements.txtThere is no install step — the package sits at the repository root and runs in place:
python -m cog_inspector scene.tifIf you want the shorter cog-inspector spelling used throughout this README, add an alias:
alias cog-inspector='python -m cog_inspector'A file that is a genuine COG:
$ cog-inspector s2_b04_cog.tif
s2_b04_cog.tif
2048x2048 px, 1 band(s), uint16, EPSG:32633
TIFF (little-endian), 512x512 tiles, DEFLATE/HORIZONTAL, PIXEL-interleaved, 60.1 KiB
Header: 1.2 KiB (1196 bytes), ~1 range request(s) to open
PASS GDAL readable
GDAL opens the file as a raster dataset.
PASS Internal tiling
Image data is tiled at 512x512.
PASS Block size [advisory]
Tile size 512x512 is a good choice for cloud reads.
PASS Overviews present
2 internal overview level(s) at decimation 2, 4.
PASS Overview layout
Overviews decimate by powers of two (2, 4) in decreasing size order.
PASS Overview depth [advisory]
The pyramid bottoms out at 512x512, small enough to render the whole scene from
one level.
PASS Overviews tiled
All 2 overview level(s) are tiled.
PASS Overviews internal
Overviews are stored inside the file, with no sidecar .ovr.
PASS Header before image data
All metadata ends at byte 1196, before the first data block at byte 1200; one
leading range request covers the whole header.
PASS IFD ordering
3 IFD(s) appear in ascending file order.
PASS Data block ordering [advisory]
Tile offsets increase monotonically and overviews precede full-resolution data.
PASS Header read cost [advisory]
The 1196 byte header fits in GDAL's initial 16384 byte read: 1 range request to
open the file.
PASS Compression [advisory]
Compressed with DEFLATE, a good choice for cloud reads. ZSTD decompresses 2-3x
faster at a comparable ratio if all readers support it.
PASS Predictor [advisory]
Predictor HORIZONTAL is applied before DEFLATE.
PASS Georeference
Georeferenced with EPSG:32633 and an affine geotransform.
VALID COG -- 15 passed, 0 failed, 0 warning(s)
$ echo $?
0
A file straight out of a processing script, which is not:
$ cog-inspector s2_b04_20240612.tif
s2_b04_20240612.tif
2048x2048 px, 1 band(s), uint16, EPSG:32633
TIFF (little-endian), stripped, DEFLATE, PIXEL-interleaved, 462.4 KiB
Header: 8.4 KiB (8564 bytes), ~1 range request(s) to open
PASS GDAL readable
GDAL opens the file as a raster dataset.
FAIL Internal tiling
Image data is stored in strips of 2 row(s), not tiles. A client cannot read a
spatial window without transferring every strip it intersects, so a small area-
of-interest read pulls full-width rows.
FAIL Overviews present
No internal overviews for a 2048x2048 image. Every zoomed-out request has to
decode full-resolution tiles, which is the single most expensive mistake in a
cloud raster.
PASS Header before image data
All metadata ends at byte 8564, before the first data block at byte 8564; one
leading range request covers the whole header.
PASS IFD ordering
1 IFD(s) appear in ascending file order.
PASS Data block ordering [advisory]
Tile offsets increase monotonically and overviews precede full-resolution data.
PASS Header read cost [advisory]
The 8564 byte header fits in GDAL's initial 16384 byte read: 1 range request to
open the file.
PASS Compression [advisory]
Compressed with DEFLATE, a good choice for cloud reads. ZSTD decompresses 2-3x
faster at a comparable ratio if all readers support it.
WARN Predictor [advisory]
No predictor on uint16 data. Horizontal differencing (PREDICTOR=2) usually
shrinks 16-bit and wider integer rasters noticeably.
PASS Georeference
Georeferenced with EPSG:32633 and an affine geotransform.
NOT A VALID COG -- 7 passed, 2 failed, 1 warning(s)
Suggested rewrite
gdal_translate s2_b04_20240612.tif s2_b04_20240612_cog.tif -of COG -co BLOCKSIZE=512 -co COMPRESS=DEFLATE -co PREDICTOR=YES -co OVERVIEW_RESAMPLING=AVERAGE
rio cogeo create s2_b04_20240612.tif s2_b04_20240612_cog.tif --blocksize 512 --cog-profile deflate --overview-resampling average
- AVERAGE resampling suits continuous data. For categorical rasters (land cover,
cloud masks) use OVERVIEW_RESAMPLING=NEAREST or MODE instead.
- ZSTD decompresses roughly 2-3x faster than DEFLATE at similar ratios; use -co
COMPRESS=ZSTD if every consumer of the file has GDAL 2.3 or newer.
$ echo $?
1
Only the header is read, using HTTP range requests — the pixel data is never downloaded:
cog-inspector https://example.com/scenes/s2_b04.tif
cog-inspector s3://sentinel-cogs/sentinel-s2-l2a-cogs/32/T/NM/2024/6/scene_B04.tif
cog-inspector /vsicurl/https://example.com/scenes/s2_b04.tif$ cog-inspector s2_b04_cog.tif --json | jq '.results[0].header'
{
"size_bytes": 1196,
"first_data_offset": 1200,
"contiguous": true,
"range_requests": 1,
"chunk_bytes": 16384
}
$ cog-inspector s2_b04_cog.tif --json | jq '.results[0].overviews'
{
"count": 2,
"factors": [2.0, 4.0],
"internal": true,
"external_ovr": false,
"all_tiled": true,
"smallest_dimension": 512
}Sweep a directory and list only what is broken:
cog-inspector *.tif --json | jq -r '.results[] | select(.summary.is_cog | not) | .target'Gate a pipeline on it — --strict turns performance warnings into a non-zero exit:
cog-inspector --strict --quiet output/*.tif || { echo "not cloud-ready"; exit 1; }from cog_inspector import inspect_target
result = inspect_target("s3://bucket/scene.tif")
if not result.is_cog:
for check in result.failures:
print(check.id, "-", check.message)
print("fix:", result.fix.gdal_translate)
print(result.header.range_requests, "range request(s) to open")rasterio and GDAL expose the raster view of a file — bands, CRS, block size, overview count. They
do not expose the byte layout, and the byte layout is the entire point of the COG format. So
cog-inspector does both: rasterio supplies the CRS, dtype and sidecar information, while a
self-contained TIFF reader in cog_inspector/tiff.py walks the file's IFD chain directly and
records where every structure physically sits.
A TIFF is a header pointing at a linked list of Image File Directories. Each IFD is a list of tags; a tag either stores its value inline (4 bytes in classic TIFF, 8 in BigTIFF) or stores a file offset to it. The parser handles both, in both byte orders, for both classic TIFF (magic 42, 32-bit offsets) and BigTIFF (magic 43, 64-bit offsets), and extracts:
- IFD offsets and their physical order. IFD 0 is the full-resolution image; subsequent IFDs are
overviews (
NewSubfileTypebit 0) or transparency masks (bit 2). TileOffsets/TileByteCounts(tags 324/325), orStripOffsets/StripByteCounts(273/279) when the file is stripped. The presence of tile tags is the tiling check.- The metadata extent — the highest byte offset touched by any IFD structure or any out-of-line tag value. This is the number of bytes a client must read before it knows the file's layout.
- The first data offset — the lowest non-zero tile/strip offset. Zero offsets are skipped rather than treated as data at byte 0, because sparse COGs legitimately store 0 for empty tiles.
| Check | What it means if it fails |
|---|---|
| Internal tiling | Strips span the full image width. Reading a 512x512 window means transferring every strip it intersects — for a 10980 px Sentinel-2 band, that is ~21x more bytes than necessary. |
| Block size | TIFF requires tile dimensions to be multiples of 16. Beyond that, tiny tiles bloat the offset arrays in the header, and huge tiles waste bandwidth per read. |
| Overviews present | Without a pyramid, every zoomed-out request decodes full-resolution tiles. This is the single most expensive mistake in a cloud raster. |
| Overview layout | Readers pick a level by walking the IFD chain in order. Overviews stored out of size order, or at non-power-of-two decimation, cause over-fetching and wrong-level selection. |
| Overviews internal | A sidecar .ovr is a second object. A client fetching only the .tif gets no pyramid, and the .ovr is routinely lost during upload or copy. |
| Header before image data | If any IFD or tag value sits after the pixel data, the client must issue an extra range request — often a seek to the end of a multi-GB object — just to learn the layout. gdal_addo on an existing file produces exactly this. |
| Data block ordering | Tiles scattered through the file prevent GDAL from merging adjacent tile reads into one range request. Overviews written after full-resolution data mean a zoomed-out client cannot just read a short prefix. |
| Header read cost | Modelled as ceil(header_bytes / 16384), matching GDAL's GDAL_INGESTED_BYTES_AT_OPEN and its /vsicurl/ chunk size. Two requests instead of one doubles your open latency on every read. |
This is the number that actually matters for cloud reads. GDAL pulls 16 KiB when it opens a remote
file. If the whole header fits in that, opening costs exactly one request. If it does not — usually
because small tiles produced a very long TileOffsets array — every open pays extra round trips
before touching a pixel. cog-inspector reports the header size in bytes and the resulting request
count, so you can see the effect of changing block size directly.
The suggested command is not a template. Flags are added only for the checks that failed:
BLOCKSIZE— keeps a square 256/512/1024 tile size if the file already has one, otherwise 512.COMPRESS— keeps the existing scheme if it is cloud-friendly (DEFLATE, ZSTD, LZW, WEBP, JPEG, LERC, JXL), otherwise DEFLATE.PREDICTOR=YES— added only for DEFLATE/ZSTD/LZW on float or 16-bit-and-wider integer data, where differencing actually pays off. It is deliberately omitted for 8-bit data.INTERLEAVE=PIXEL— added only when the source is band-interleaved with more than one band.OVERVIEW_RESAMPLING=AVERAGE— added only when the pyramid is missing, shallow, stripped, external or misordered.BIGTIFF=YES— added when the source is already BigTIFF.
| Flag | Effect |
|---|---|
TARGET... |
One or more GeoTIFFs: local paths, http(s):// URLs, s3:///gs:///az:// URLs, or ready-made /vsi*/ paths. |
--json |
Emit {"strict": ..., "results": [...], "summary": {...}} instead of the text report. |
--strict |
Treat performance warnings as failures when deciding the exit code. |
--show-skipped |
Include checks that did not apply to this file. |
--quiet, -q |
Print nothing; communicate the verdict through the exit code only. |
--no-color |
Disable ANSI colour. NO_COLOR in the environment does the same. |
--timeout SECONDS |
Socket timeout for remote reads (default 30). |
--version |
Print the version and exit. |
Exit codes: 0 every target is a valid COG, 1 at least one target failed a check or could not be
read as a TIFF, 2 usage error.
| Function | Purpose |
|---|---|
inspect_target(target, *, timeout=30.0) -> Inspection |
Inspect one file. Parse failures are reported through Inspection.error, never raised. |
read_tiff_structure(source, max_ifds=256) -> TiffStructure |
Parse the IFD chain of any TIFF/BigTIFF from a ByteSource. |
to_vsi_path(target) -> str |
Translate a path or URL into a GDAL virtual filesystem path. |
estimate_range_requests(header_bytes, chunk=16384) -> int |
The range-request cost model. |
Inspection exposes checks, failures, warnings, passes, raster, header, overviews,
fix, is_cog, ok(strict=False), check(id) and to_dict().
- Missing overviews are reported as FAIL, not WARN, for images larger than 512 px. GDAL's own
validate_cloud_optimized_geotiff.pytreats this as a warning. We disagree: a large COG without a pyramid forces full-resolution reads for every zoomed-out request, which is the failure mode the format exists to prevent. Use--jsonand filter bycategoryif you want GDAL's grouping. - Non-HTTP remote schemes need the GDAL Python bindings. Local files and
http(s)://URLs are read natively.s3://,gs://andaz://structural parsing goes throughosgeo.gdal.VSIFOpenL, which is not bundled with rasterio wheels. Without it you get a clear error; use a presigned or public HTTPS URL instead. - Compression is identified, not verified. The tool reads the Compression tag; it does not decode tiles, so it cannot detect corrupt compressed streams.
- Lossy compression is not judged. WEBP and JPEG pass the compression check. Whether lossy compression is acceptable for your data is a decision the tool cannot make.
- The range-request model assumes GDAL's defaults. Clients that set
GDAL_INGESTED_BYTES_AT_OPENorCPL_VSIL_CURL_CHUNK_SIZEdifferently, or non-GDAL readers, will see different numbers. - Only the TIFF container is inspected. STAC metadata, band descriptions and colour interpretation are out of scope.
Background on the format and the access patterns this tool measures:
- Understanding Cloud-Optimized GeoTIFF structure — how tiling, overviews and IFD ordering fit together.
- How to read COG headers without downloading full files — the range-request mechanics behind the header cost estimate.
- Reading a COG over S3 without downloading it — practical
/vsis3/and windowed-read setup. - Choosing COG compression: ZSTD vs DEFLATE — the tradeoff behind the compression and predictor advice.
- Reducing S3 egress costs in raster pipelines — why an extra range request per open adds up at scale.
Issues and pull requests are welcome. Please keep the test suite hermetic — no network calls;
generate synthetic GeoTIFFs with rasterio in tests/conftest.py, or hand-build TIFF bytes with the
helper in tests/tiffbuild.py for layouts GDAL will not write.
pip install -r requirements-dev.txt
ruff check . && ruff format --check .
python -m pytestMIT — see LICENSE.
Built and maintained alongside Python Remote Sensing & Raster Processing Pipelines.