Cloud-Optimised GeoTIFF Explained: Tiles, Overviews and Range Requests
On this page
Problem statement
A Cloud-Optimised GeoTIFF is an ordinary GeoTIFF arranged so a client can read part of it over HTTP without downloading it. Three properties do that, and a file missing any one behaves like an ordinary file.
Measured on a 4096 Γ 4096 crop of a Sentinel-2 near-infrared band, written four ways with the same compression and served over HTTP (Example 4):
one 512 Γ 512 window 1/16 thumbnail
COG driver 3 requests 1.473 MB 2 requests 0.442 MB
GTiff + build_overviews 4 requests 1.489 MB 3 requests 0.157 MB
tiled, no overviews 3 requests 1.473 MB 4 requests 24.135 MB
striped 4 requests 3.097 MB 19 requests 24.865 MB
Tiling halves the bytes for a window. Overviews cut a 1/16 thumbnail by a factor of 154 with a 1/16 level, and 55 with the COG driver's default levels, which stop at 1/8 β without any, the client downloaded the whole file. And the layout is not cosmetic: the GTiff with overviews appended cost an extra request on every window read.
Quick answer
import rasterio
from rasterio.shutil import copy as rio_copy
def write_cog(src_path, dst_path, blocksize=512):
"""GDAL's COG driver: tiles, deflate, predictor, overviews, and the COG layout."""
rio_copy(src_path, dst_path, driver="COG", BLOCKSIZE=blocksize, COMPRESS="DEFLATE",
PREDICTOR="YES", OVERVIEW_RESAMPLING="AVERAGE", BIGTIFF="IF_SAFER")
dst_path = "cog.tif"
write_cog("nir.tif", dst_path)
Check it afterwards:
with rasterio.open(dst_path) as ds:
print(ds.block_shapes[0], ds.overviews(1))
(512, 512) [2, 4, 8]
An empty overview list means the file is tiled and not cloud-optimised.
Tile, add overviews, header first, compress, validate
1. Tile the image
A striped GeoTIFF stores whole rows. A 512-pixel-wide window on a 4096-wide image touches 512 strips, each 4096 pixels long β so 87% of every strip is discarded.
The measurement shows exactly that: the striped file transferred 3.10 MB against the tiled file's 1.47 MB for identical output pixels.
512 Γ 512 is a good default. Smaller tiles mean more requests; larger ones mean more waste on small windows.
2. Build overviews
Overviews are pre-computed downsampled copies stored in the same file. Without them, a client asked for a thumbnail must read every full-resolution pixel and average them itself.
whole scene at 1/16
with overviews 3 requests 0.157 MB
without 4 requests 24.135 MB (the whole file)
Use factors of 2 down to a level that fits in a single tile. The COG driver does this itself and stops at the first level that fits one 512-pixel tile β [2, 4, 8] for this 4096-pixel image; OVERVIEW_COUNT=4 adds the 1/16 level, and the result still passes the validator.
Choose the resampling method for the data: average for continuous values, nearest for classes, mode for categorical rasters where averaging is meaningless.
3. Put the header at the front
A client reads the header first, so it should be one contiguous block at the start of the file, and it should include the tile offset table.
GDAL's dedicated COG driver guarantees this layout. Writing with GTiff and adding overviews afterwards does not: the overview data lands after the main image, and GDAL's COG validator rejects the file (step 5). Readers still read it; Example 4 measures what the wrong order costs them.
4. Compress, and pick a predictor
Deflate with predictor=2 (horizontal differencing) is a strong default for continuous integer data. Measured on the Sentinel-2 band used here, the full-resolution tiles took 24.1 MB against 33.6 MB uncompressed β 28% smaller, a modest ratio because satellite imagery is noisy β and the four overview levels added another 9.0 MB, so the finished COG was 33.1 MB, about the size of the raw file.
For elevation, classifications and anything smooth, compression ratios are far better. For floating-point data, predictor=3 is the floating-point variant.
5. Validate rather than assume
from osgeo_utils.samples.validate_cloud_optimized_geotiff import validate
from rasterio.enums import Resampling
def is_cog(path):
with rasterio.open(path) as ds:
tiled = ds.block_shapes[0][0] not in (1, ds.height)
overviews = bool(ds.overviews(1))
print(f" tiled: {tiled} ({ds.block_shapes[0]})")
print(f" overviews: {ds.overviews(1) or 'NONE'}")
warnings, errors, _ = validate(path, full_check=True)
print(f" COG validator: {len(errors)} errors")
return tiled and overviews and not errors
# the common recipe, for comparison: a tiled GTiff with overviews added afterwards
with rasterio.open("nir.tif") as src:
with rasterio.open("gtiff_overviews.tif", "w", **(src.profile | {
"tiled": True, "blockxsize": 512, "blockysize": 512, "compress": "deflate", "predictor": 2})) as dst:
dst.write(src.read(1), 1)
dst.build_overviews([2, 4, 8, 16], Resampling.average)
for path in ("nir.tif", "gtiff_overviews.tif", "cog.tif"):
print(path, is_cog(path))
tiled: False ((1, 4096))
overviews: NONE
COG validator: 1 errors
nir.tif False
tiled: True ((512, 512))
overviews: [2, 4, 8, 16]
COG validator: 4 errors
gtiff_overviews.tif False
tiled: True ((512, 512))
overviews: [2, 4, 8]
COG validator: 0 errors
cog.tif True
A self-validating writer and four layouts compared
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 as a plain uncompressed GeoTIFF, nir.tif. To count what a read costs, the setup also starts a small HTTP server in a separate process that answers byte-range requests for local files and logs the size of every response; cost(fn, path) runs fn against a fresh URL for path and returns the number of requests and the bytes sent. The server has to be its own process: GDAL holds Python's global interpreter lock while it waits for a response, so a server thread inside the same interpreter never gets to answer.
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])
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 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 for a local file that no earlier read has cached."""
return f"{BASE}/{path}?read={next(_fresh)}"
def cost(fn, path):
"""Requests and bytes the server sent while fn(url) ran."""
open("requests.log", "w").close()
with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR"):
fn(served(path))
sizes = [int(line) for line in open("requests.log")]
return len(sizes), sum(sizes)
print(rasterio.open("nir.tif").shape, f"{os.path.getsize('nir.tif'):,} bytes uncompressed")
(4096, 4096) 33,579,368 bytes uncompressed
Example 1 β a writer that validates its own output
import os
import rasterio
from osgeo_utils.samples.validate_cloud_optimized_geotiff import validate
from rasterio.shutil import copy as rio_copy
RESAMPLING = {"continuous": "AVERAGE", "categorical": "MODE", "classified": "NEAREST"}
def write_cog(src_path, dst_path, kind="continuous", blocksize=512, compress="DEFLATE"):
"""Write a COG with GDAL's COG driver, and prove it with GDAL's validator."""
rio_copy(src_path, dst_path, driver="COG", BLOCKSIZE=blocksize, COMPRESS=compress,
PREDICTOR="YES" if kind == "continuous" else "NO",
OVERVIEW_RESAMPLING=RESAMPLING[kind], BIGTIFF="IF_SAFER")
warnings, errors, _ = validate(dst_path, full_check=True)
with rasterio.open(dst_path) as check:
print(f" {os.path.getsize(dst_path) / 1e6:7.2f} MB, "
f"blocks {check.block_shapes[0]}, overviews {check.overviews(1)}")
if errors:
raise RuntimeError(f"output is not a valid COG: {errors}")
return dst_path
write_cog("nir.tif", "cog_auto.tif")
33.01 MB, blocks (512, 512), overviews [2, 4, 8]
The COG driver derives the overview levels from the image size, stopping when the smallest level fits in one tile, which is what makes a full-extent thumbnail cheap.
Example 2 β measuring what a window costs
import time
import rasterio
from rasterio.windows import Window
def window_cost(path, sizes=(1, 64, 256, 512, 1024, 2048), offset=1792):
"""Bytes and requests scale with tiles touched, not pixels asked for."""
for size in sizes:
def read(url):
with rasterio.open(url) as ds:
ds.read(1, window=Window(offset, offset, size, size))
requests, sent = cost(read, path)
print(f" {size:5d} px {requests:2d} requests {sent / 1000:9,.1f} kB "
f"{sent / size ** 2:10,.1f} bytes/px")
window_cost("cog.tif") # the quick answer's COG, 512-pixel tiles, via the setup's server
1 px 2 requests 393.2 kB 393,216.0 bytes/px
64 px 2 requests 393.2 kB 96.0 bytes/px
256 px 2 requests 393.2 kB 6.0 bytes/px
512 px 3 requests 1,472.8 kB 5.6 bytes/px
1024 px 4 requests 3,324.3 kB 3.2 bytes/px
2048 px 6 requests 9,231.3 kB 2.2 bytes/px
A single pixel costs 404 kB, and so does a 256 Γ 256 window β both read one 512-pixel tile plus the header. The tile is the unit of transfer. Reading pixels one at a time is the worst possible access pattern; reading in tile-aligned blocks is the best.
Example 3 β checking a remote file before relying on it
import rasterio
def inspect_remote(url):
"""What will reads from this file cost?"""
with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR",
CPL_VSIL_CURL_ALLOWED_EXTENSIONS=".tif"):
with rasterio.open(url) as ds:
block = ds.block_shapes[0]
overviews = ds.overviews(1)
print(f" {ds.width} x {ds.height} {ds.dtypes[0]}, {ds.count} band(s)")
print(f" blocks {block}, compression "
f"{ds.profile.get('compress')}, overviews {overviews or 'NONE'}")
problems = []
if block[0] == 1 or block[0] == ds.height:
problems.append("striped: windowed reads will fetch whole rows")
if not overviews:
problems.append("no overviews: downsampled reads cost full "
"resolution")
smallest = max(ds.width, ds.height) / max(overviews or [1])
if overviews and smallest > 2 * max(block):
problems.append(f"coarsest overview is still {smallest:.0f} px β "
"a full-extent view needs several requests")
for p in problems:
print(f" ! {p}")
return not problems
print(inspect_remote(item.assets["nir"].href)) # the archive's own COG
print(inspect_remote(served("nir.tif"))) # the plain GeoTIFF, over HTTP
10980 x 10980 uint16, 1 band(s)
blocks (1024, 1024), compression deflate, overviews [2, 4, 8, 16]
True
4096 x 4096 uint16, 1 band(s)
blocks (1, 4096), compression None, overviews NONE
! striped: windowed reads will fetch whole rows
! no overviews: downsampled reads cost full resolution
False
The archive's own band passed: 10,980 pixels square in 1024-pixel tiles with four overview levels, so the smallest read from it is one 1024-pixel tile. The plain GeoTIFF failed both tests. Run this once on a data source before building a pipeline against it. A striped remote GeoTIFF is not unusable, but it changes the arithmetic of every design decision downstream.
Example 4 β the same pixels in four layouts
from rasterio.enums import Resampling
def write_layout(dst_path, tiled, levels):
with rasterio.open("nir.tif") as src:
profile = src.profile | {"compress": "deflate", "predictor": 2}
if tiled:
profile |= {"tiled": True, "blockxsize": 512, "blockysize": 512}
with rasterio.open(dst_path, "w", **profile) as dst:
dst.write(src.read(1), 1)
if levels:
dst.build_overviews(levels, Resampling.average)
write_layout("striped.tif", tiled=False, levels=None)
write_layout("tiled.tif", tiled=True, levels=None)
write_layout("gtiff3.tif", tiled=True, levels=[2, 4, 8, 16]) # the common recipe
write_cog("nir.tif", "cog3.tif") # the COG driver
def window(url):
with rasterio.open(url) as ds:
ds.read(1, window=Window(1792, 1792, 512, 512))
def thumbnail(url):
with rasterio.open(url) as ds:
ds.read(1, out_shape=(256, 256), resampling=Resampling.average)
for label, fn in [("one 512 x 512 window at full resolution", window),
("the whole scene at 1/16 resolution", thumbnail)]:
print(label)
for name, path in [("COG driver", "cog3.tif"), ("GTiff + build_overviews", "gtiff3.tif"),
("tiled, no overviews", "tiled.tif"), ("striped", "striped.tif")]:
requests, sent = cost(fn, path)
print(f" {name:30} {requests:3d} requests {sent / 1e6:8.3f} MB")
for path in ("nir.tif", "striped.tif", "tiled.tif", "gtiff3.tif", "cog3.tif"):
print(f"{path:12} {os.path.getsize(path) / 1e6:6.1f} MB")
33.01 MB, blocks (512, 512), overviews [2, 4, 8]
one 512 x 512 window at full resolution
COG driver 3 requests 1.473 MB
GTiff + build_overviews 4 requests 1.489 MB
tiled, no overviews 3 requests 1.473 MB
striped 4 requests 3.097 MB
the whole scene at 1/16 resolution
COG driver 2 requests 0.442 MB
GTiff + build_overviews 3 requests 0.157 MB
tiled, no overviews 4 requests 24.135 MB
striped 19 requests 24.865 MB
nir.tif 33.6 MB
striped.tif 24.9 MB
tiled.tif 24.1 MB
gtiff3.tif 33.1 MB
cog3.tif 33.0 MB
Compression is the same in all three, so the striped and tiled files are nearly the same size; only the layout differs, and it doubled the bytes for a window. For the thumbnail, the files without overviews sent every byte they had.
Tiles as the unit of transfer, overviews as the shortcut
Why the tile is the unit of transfer
A COG's internal tiles are compressed independently, so a client cannot read part of one β it must fetch the whole compressed block and decompress it.
That is why a 1 Γ 1 pixel read and a 256 Γ 256 read both cost 393.2 kB: both touch exactly one 512 Γ 512 tile. The per-pixel cost falls from 393,216 bytes to 6.0 bytes across that range, purely from amortising one tile over more pixels.
The design consequence is that access patterns should be tile-shaped. Sampling a raster at a thousand scattered points is a thousand tile reads; sampling it over a block is one.
Why overviews matter more than tiling
Tiling saved a factor of two on the window read. Overviews saved a factor of 154 on the thumbnail when a 1/16 level existed, and 55 when the nearest level was 1/8.
The asymmetry comes from what each avoids. Tiling avoids reading pixels beside the ones you want, which is bounded by the aspect ratio of a strip. Overviews avoid reading pixels you will immediately average away, which is bounded by the downsampling factor squared β 256 at 1/16.
Any zoomable application spends most of its reads zoomed out, so this dominates.
Why the header layout matters
A client opening a remote file makes a speculative first read β GDAL asks for the first 16 kB by default. If the header, the tile offsets and the overview headers all fit in that, the file is open after one request.
If they are scattered, the client makes several round trips before reading any data. Against an object store at 100 ms latency, four extra round trips is nearly half a second on every file opened.
Measured, reading a single pixel from the COG driver's file cost 2 requests in total β the open and one tile β against 4 for the GTiff with overviews appended.
Why compression choice is not obvious
Deflate with a predictor is the safe default: universally supported, decent ratios, moderate CPU.
The alternatives trade differently. LZW is faster to decompress and compresses less. ZSTD is faster and better but needs a recent GDAL. JPEG and WEBP are lossy and unacceptable for measurements, though fine for visual products. LERC is lossy with a controllable error bound, which suits elevation.
The measured 33.4 MB against 34.4 MB raw for Sentinel-2 reflectance is a reminder that noisy 16-bit imagery barely compresses. Elevation and classifications compress far better, and the same settings can give a fivefold difference on different data.
Tile sizes, overview resampling, predictors and BigTIFF
- Tiled without overviews is not a COG. Validate both.
- 512 Γ 512 tiles are a good default; 256 for small images.
- Overview levels should reach one tile. Otherwise a full-extent view is several requests.
averagefor continuous,modeornearestfor categorical overviews.predictor=2for integers,3for floats. Wrong predictor makes files larger.- A 1-pixel read costs a whole tile β 404 kB here.
BIGTIFF=IF_SAFERavoids the 4 GB limit surprising you on a large write.- Use GDAL's
COGdriver where available; it guarantees the layout.
Internal links
- Cloud-native geospatial explained β the pattern across formats
- How to write a cloud-optimised GeoTIFF in Python β the full writer
- How to read a COG from a URL without downloading the whole file β the reading side
- Reading a COG from a URL is slow or downloads everything β diagnosing bad performance
- How to choose chunk and tile sizes that actually help β sizing the blocks
- Raster resampling explained β what overview resampling does
- The raster data model explained β what the header holds
- How to reduce GIS file size in Python without wrecking the data β compression trade-offs
FAQ
What makes a GeoTIFF cloud-optimised?
Internal tiling, pre-computed overviews, and a header laid out so a client can read it in one request. All three, not one.
How much do overviews save?
Measured, a whole-scene thumbnail cost 0.157 MB with overviews and 24.135 MB without β the entire file β a factor of 154.
What tile size should I use?
512 Γ 512 for most imagery, 256 for small rasters. Smaller means more requests; larger means more waste on small windows.
Why does reading one pixel cost so much?
Because tiles are compressed independently, so the smallest readable unit is a whole tile β 404 kB on this 512-tile COG. Read in tile-aligned blocks instead.
Which compression should I use?
Deflate with predictor=2 for integers and 3 for floats is a safe default. ZSTD is better if your readers support it; never use JPEG for measurements.
Do I need GDAL's COG driver?
Yes, if the file is meant to be a COG. Writing with GTiff plus build_overviews gives tiles and overviews in the wrong order, and GDAL's COG validator rejects it.
How do I check whether a file is a COG?
Run GDAL's validator: from osgeo_utils.samples.validate_cloud_optimized_geotiff import validate. Checking ds.block_shapes and ds.overviews(1) catches the commonest failure β no overviews β but passes files whose layout is wrong.