Export a Cloud Optimized GeoTIFF in PyQGIS
A Cloud Optimized GeoTIFF (COG) is an ordinary GeoTIFF with its internal layout arranged for partial reading: data stored in tiles, overviews included, and the index of everything at the start of the file. A client that wants one small area at one zoom level reads the header, then fetches just the few tiles it needs with HTTP range requests. That makes a COG on object storage behave almost like a tile service with no server, and makes the same file fast and compact on a local disk.
This recipe belongs to Raster Analysis Workflows. It writes COGs with GDAL's COG driver through Processing, chooses compression and overview settings for different data types, validates the layout, and loads the result back from object storage. Reading existing COGs is covered in reading a Cloud Optimized GeoTIFF.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series. The COG driver is part of GDAL 3.1 and later, which every current QGIS bundles.
- A raster to publish — a DEM, an orthophoto, a classification — ideally already in its final CRS.
Write a COG with gdal:translate
gdal:translate with the COG output format writes a fully compliant file in one step: tiling, overviews and the header layout are handled by the driver.
import processing
from qgis.core import QgsRasterLayer
src = "/data/dem/dem_mosaic.tif"
out = processing.run("gdal:translate", {
"INPUT": src,
"TARGET_CRS": None,
"NODATA": -9999,
"COPY_SUBDATASETS": False,
"OPTIONS": "",
"EXTRA": "-of COG -co COMPRESS=DEFLATE -co PREDICTOR=FLOATING_POINT "
"-co BLOCKSIZE=512 -co OVERVIEW_RESAMPLING=AVERAGE -co BIGTIFF=IF_SAFER",
"DATA_TYPE": 0,
"OUTPUT": "/data/publish/dem_cog.tif",
})["OUTPUT"]
cog = QgsRasterLayer(out, "DEM (COG)")
print(cog.isValid(), cog.width(), "x", cog.height(), cog.dataProvider().dataType(1))
Breakdown: -of COG selects the COG driver; its creation options go after -co. The driver builds overviews automatically, so there is no separate overview step. Deflate with the floating-point predictor suits elevation and other continuous float data, shrinking files considerably without loss. A 512-pixel block size is a good balance between request count and request size for both local and remote reading. Average resampling for overviews keeps zoomed-out views of continuous data smooth. BIGTIFF=IF_SAFER avoids failures for outputs approaching 4 GB.
Choose compression by data type
Compression decides both file size and fidelity. The right choice depends on what the raster holds.
PROFILES = {
"continuous": "-co COMPRESS=DEFLATE -co PREDICTOR=FLOATING_POINT -co OVERVIEW_RESAMPLING=AVERAGE",
"categorical": "-co COMPRESS=DEFLATE -co PREDICTOR=STANDARD -co OVERVIEW_RESAMPLING=MODE",
"imagery": "-co COMPRESS=JPEG -co QUALITY=85 -co OVERVIEW_RESAMPLING=AVERAGE",
}
def write_cog(src, dst, kind, nodata=None):
extra = f"-of COG -co BLOCKSIZE=512 -co BIGTIFF=IF_SAFER {PROFILES[kind]}"
return processing.run("gdal:translate", {
"INPUT": src, "NODATA": nodata, "EXTRA": extra, "DATA_TYPE": 0,
"OUTPUT": dst})["OUTPUT"]
write_cog("/data/landcover/classified_2026.tif", "/data/publish/landcover_cog.tif",
"categorical", nodata=0)
write_cog("/data/ortho/ortho_2026.tif", "/data/publish/ortho_cog.tif", "imagery")
Breakdown: Named profiles keep the decision in one place and make it hard to compress a land-cover map with JPEG by accident. Mode resampling for categorical overviews keeps class codes valid at every zoom level, for the same reason as in resampling and aligning rasters. JPEG works on 8-bit RGB imagery only; for imagery with an alpha band or more bands, use WEBP or a lossless option. ZSTD compresses faster and often smaller than DEFLATE where readers support it — recent GDAL everywhere, but check web clients.
Validate the layout
A file can be a valid GeoTIFF without being a valid COG — for example after being edited in place, which moves data after the index. GDAL's validation checks the layout.
from osgeo import gdal
def cog_report(path):
ds = gdal.Open(path)
md = ds.GetMetadata("IMAGE_STRUCTURE")
band = ds.GetRasterBand(1)
return {"layout": md.get("LAYOUT"), "compression": md.get("COMPRESSION"),
"block": band.GetBlockSize(), "overviews": band.GetOverviewCount()}
info = cog_report(out)
print(info)
assert info["layout"] == "COG", "not a COG layout"
assert info["overviews"] > 0, "no overviews"
Breakdown: GDAL reports LAYOUT=COG in the image structure metadata for files written by the COG driver with an intact layout. The block size should be the tiled size you asked for, not a strip such as (width, 1). The overview count shows that zoomed-out reads will be cheap. For a full structural check, GDAL ships a validate_cloud_optimized_geotiff.py script that can be run on the file; the metadata check above is enough for files you produce yourself.
Serve from object storage
A COG's advantages show when it is read remotely. Upload it to any object storage that supports HTTP range requests — S3, Azure Blob, Google Cloud Storage, a plain web server — and QGIS opens it by URL.
from qgis.core import QgsProject
url = "/vsicurl/https://data.example.org/publish/dem_cog.tif"
remote = QgsRasterLayer(url, "DEM (remote COG)", "gdal")
print(remote.isValid(), remote.width(), "x", remote.height())
QgsProject.instance().addMapLayer(remote)
Breakdown: The /vsicurl/ prefix tells GDAL to read the file over HTTP with range requests. QGIS fetches the header once, then only the tiles needed for each view — a national DEM displays at country scale after downloading a few hundred kilobytes. For private buckets, GDAL's /vsis3/ and /vsiaz/ handlers read credentials from environment variables or configuration options. The details of authentication, caching and performance are in reading a Cloud Optimized GeoTIFF.
Keep metadata with the file
A COG published for others should describe itself. GeoTIFF tags hold short metadata — a description, units, the source and processing date — which QGIS shows in Layer Properties and GDAL reports with gdalinfo.
ds = gdal.Open(out, gdal.GA_Update)
ds.SetMetadata({"TIFFTAG_IMAGEDESCRIPTION": "Digital elevation model, 1 m, metres above datum",
"SOURCE": "dem_mosaic.tif (2026 survey tiles)",
"PROCESSED": "2026-10-02"})
ds.GetRasterBand(1).SetDescription("elevation_m")
ds = None
print(cog_report(out)["layout"])
Breakdown: Writing small metadata items into an existing file is one of the few in-place edits that GDAL can usually make without disturbing the COG layout, because the tags fit in the header's reserved space; checking the layout again afterwards confirms it. For anything larger — a full metadata record — publish a sidecar file or a STAC item next to the COG instead. Setting the band description gives the band a readable name in QGIS's band selector.
Measure what the conversion gained
Compression and layout choices are easiest to judge with numbers from your own data: file size, and how much has to be read to show a zoomed-out view.
import os
def mb(path):
return os.path.getsize(path) / 1e6
before, after = mb(src), mb(out)
print(f"source {before:,.0f} MB → COG {after:,.0f} MB ({after / before:.0%})")
ds = gdal.Open(out)
band = ds.GetRasterBand(1)
for i in range(band.GetOverviewCount()):
ov = band.GetOverview(i)
print(f"overview {i}: {ov.XSize} × {ov.YSize}")
Breakdown: The size ratio shows what compression achieved; for elevation data with the floating-point predictor, a result well under half the original is common, while already-compressed or noisy imagery shrinks less. Listing overview sizes shows how small the zoomed-out reads are — the smallest overview is typically a few hundred pixels across, which is all a client fetches to draw the whole extent. If the source was already a tiled, compressed GeoTIFF with overviews, gains are smaller, and the main benefit of the COG layout is efficient remote reading.
Convert a folder in one run
Publishing a dataset usually means converting many files with one consistent profile. A loop with a skip for files already converted makes the job restartable.
from pathlib import Path
src_dir, dst_dir = Path("/data/ortho/sheets"), Path("/data/publish/ortho")
dst_dir.mkdir(parents=True, exist_ok=True)
for tif in sorted(src_dir.glob("*.tif")):
dst = dst_dir / tif.name
if dst.exists() and cog_report(str(dst))["layout"] == "COG":
continue
write_cog(str(tif), str(dst), "imagery")
print("written", dst.name)
Breakdown: Skipping outputs that already exist and validate as COGs means a crashed or interrupted run can simply be restarted. For very large folders, the batch processing patterns for parallel runs and checkpoints apply directly. If the sheets form a mosaic, consider building a VRT and writing one COG from it, which is usually easier for clients than hundreds of separate files.
QGIS version compatibility
The COG driver is available in the GDAL bundled with QGIS 3.34 LTR, 3.40 LTR and QGIS 4. Creation options such as PREDICTOR=FLOATING_POINT, OVERVIEW_RESAMPLING and ZSTD compression depend on GDAL 3.2 or newer, which all current releases include. /vsicurl/ reading works on all platforms.
Troubleshooting
- The file is much bigger than expected. No compression options were passed, or a predictor was missing for float data.
- Categorical overviews show odd values. Overviews used average resampling; use mode.
LAYOUTis not COG. The file was written by a different driver or modified afterwards; rewrite it with-of COG.- Remote reads are slow. The server does not support range requests, or the file has no overviews.
Conclusion
Write COGs with gdal:translate -of COG, choose lossless predictors for analytical data and lossy compression only for viewed imagery, match overview resampling to the data type, validate the layout, serve from any range-request-capable storage and read with /vsicurl/, and convert folders with a restartable loop.
Frequently Asked Questions
Is a COG slower than a normal GeoTIFF locally? No. Tiling and overviews usually make it faster to display than a striped file without overviews.
Can I update a COG in place? Not without breaking the layout. Rewrite it from the source.
Do web maps read COGs directly? Many do — OpenLayers, deck.gl and TiTiler-based services among them.
What block size should I use? 512 is a good default; 256 suits small rasters and high-latency connections.