Read a Cloud-Optimized GeoTIFF in PyQGIS
A cloud-optimized GeoTIFF is an ordinary GeoTIFF with its bytes arranged so that a reader can fetch exactly the part it needs over HTTP. Internally tiled, with overviews, and with the header at the front — that is the whole specification. The consequence is disproportionate: a 40 GB national elevation model becomes a layer you add in a second and render from a few hundred kilobytes, without downloading anything or having anywhere to put it if you did.
This recipe belongs to Web Services and Remote Data in PyQGIS. It covers opening a COG from a URL or a bucket, the settings that decide whether it is fast or painful, verifying that a file really is cloud-optimized, and producing one.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer — GDAL 3.4 or above, which every 3.x LTS build ships.
- A COG URL, or credentials for the bucket holding one.
- Nothing installed: the virtual file system is part of GDAL and needs no extra packages.
Open a COG from a URL
from qgis.core import QgsRasterLayer, QgsProject
url = "/vsicurl/https://storage.example.org/dem/national_dem_cog.tif"
dem = QgsRasterLayer(url, "National DEM", "gdal")
if not dem.isValid():
raise RuntimeError(f"could not open COG: {dem.error().summary()}")
print(dem.width(), dem.height(), dem.bandCount())
print(dem.extent().toString(2))
QgsProject.instance().addMapLayer(dem)
Breakdown: The /vsicurl/ prefix tells GDAL to read the file over HTTP with range requests rather than downloading it, and everything after the prefix is an ordinary URL. The layer is a normal gdal raster: it has an extent, a band count, statistics, and every raster algorithm accepts it. The dimensions printed here come from the header alone, typically one request of a few kilobytes against a file of many gigabytes — which is the whole trick. If isValid() is False, the usual causes are a URL that redirects (GDAL follows redirects but not all of them), a server that does not support range requests, or a file that is not really a GeoTIFF.
For object storage, swap the prefix and let GDAL pick up credentials from the environment:
import os
os.environ["AWS_S3_ENDPOINT"] = "s3.eu-west-2.amazonaws.com"
os.environ["AWS_ACCESS_KEY_ID"] = os.environ["MY_KEY_ID"]
os.environ["AWS_SECRET_ACCESS_KEY"] = os.environ["MY_SECRET"]
layer = QgsRasterLayer("/vsis3/my-bucket/imagery/2026-06-mosaic.tif", "Mosaic", "gdal")
Breakdown: /vsis3/ reads from S3-compatible storage, /vsiaz/ from Azure Blob Storage, /vsigs/ from Google Cloud Storage, each with its own environment variables. Reading the secrets from the environment rather than writing them in the script is the point — the same discipline as the authentication database for services, applied where GDAL rather than QGIS is doing the fetching. For public buckets, AWS_NO_SIGN_REQUEST=YES avoids the need for credentials entirely.
Tune the request behaviour
The defaults are conservative. Three settings decide whether a COG feels local or sluggish.
from osgeo import gdal
gdal.SetConfigOption("GDAL_DISABLE_READDIR_ON_OPEN", "EMPTY_DIR")
gdal.SetConfigOption("CPL_VSIL_CURL_ALLOWED_EXTENSIONS", ".tif,.tiff,.ovr")
gdal.SetConfigOption("VSI_CACHE", "TRUE")
gdal.SetConfigOption("VSI_CACHE_SIZE", "50000000") # 50 MB per file
Breakdown: GDAL_DISABLE_READDIR_ON_OPEN set to EMPTY_DIR stops GDAL listing the whole remote directory when opening one file, which on a bucket of ten thousand scenes is the difference between opening in a second and opening in a minute. Restricting the allowed extensions prevents speculative requests for sidecar files that do not exist — each one a wasted round trip. The VSI cache keeps recently read blocks in memory, so panning back to where you were does not re-fetch. Set these once at the start of a script, before opening any remote raster; they are process-wide.
Check whether a file really is cloud-optimized
A file named _cog.tif is not necessarily one. GDAL ships a validator, and the answer changes how you should use the file.
from osgeo import gdal
info = gdal.Info("/vsicurl/https://storage.example.org/dem/national_dem_cog.tif",
format="json")
print(info["metadata"].get("IMAGE_STRUCTURE", {}))
print([band.get("overviews", []) for band in info["bands"]])
print(info.get("blockSize"))
Breakdown: Three properties decide it: the file must be internally tiled rather than stripped, which shows up as a block size like 512 by 512 instead of a full-width strip; it must carry overviews inside the file rather than in a .ovr sidecar; and the header must sit at the front. A file failing the first two is still readable over /vsicurl/, but each render will pull far more bytes than it should — often the whole file — which is exactly the situation the format exists to prevent. GDAL's validate_cloud_optimized_geotiff.py script gives a definitive verdict if you need one for a data-publishing checklist.
Write a COG of your own
Anything you produce for others to read over the network should be one, and it is a single algorithm call.
import processing
processing.run("gdal:translate", {
"INPUT": "/data/outputs/slope.tif",
"OPTIONS": "COMPRESS=DEFLATE|PREDICTOR=2|TILED=YES|COPY_SRC_OVERVIEWS=YES",
"DATA_TYPE": 0,
"OUTPUT": "/data/publish/slope_cog.tif",
})
Breakdown: TILED=YES gives the internal tiling; COPY_SRC_OVERVIEWS=YES moves existing overviews into the file, so build them first with gdal:overviews if the source has none. DEFLATE with a horizontal predictor compresses continuous data such as elevation well; for categorical rasters drop the predictor. DATA_TYPE of 0 means "use the input type", which avoids the silent precision loss of an unintended conversion. Newer GDAL versions accept COG as an output driver directly, which handles the layout rules for you and is worth preferring where available. The Processing patterns around this call are covered in Run a Processing Algorithm from a Script.
Mosaic many remote rasters into one layer
Archives publish scenes, not countries: a thousand COGs tiling an area, each a separate file. A GDAL virtual raster stitches them into a single layer without copying a byte.
from osgeo import gdal
scenes = [
"/vsicurl/https://storage.example.org/dem/tile_0001.tif",
"/vsicurl/https://storage.example.org/dem/tile_0002.tif",
"/vsicurl/https://storage.example.org/dem/tile_0003.tif",
]
gdal.BuildVRT("/data/cache/national_dem.vrt", scenes,
options=gdal.BuildVRTOptions(resolution="highest", addAlpha=False))
mosaic = QgsRasterLayer("/data/cache/national_dem.vrt", "National DEM", "gdal")
print(mosaic.isValid(), mosaic.extent().toString(2))
Breakdown: BuildVRT writes a small XML file listing the sources and their positions; the pixels stay where they are, so the local artefact is a few hundred kilobytes describing terabytes. Reading it opens only the sources that intersect the current view, which combines with the range-request behaviour to give a national mosaic that renders like a local file. resolution="highest" keeps the finest pixel size among the inputs, which is the safe default when scenes differ; mixing resolutions without saying so produces a mosaic quietly resampled to the coarsest. Sources must share a coordinate system — build a warped VRT with gdal.Warp first if they do not.
The VRT is also the right place to attach a scene list produced from a catalogue query, which is how most cloud archives are meant to be used: search the catalogue for what intersects your area of interest, feed the resulting URLs into BuildVRT, and treat the result as an ordinary raster layer for the rest of the analysis.
QGIS version compatibility
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | GDAL 3.2+; /vsicurl/ and /vsis3/ available, COG driver present in most builds. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page; GDAL 3.8 with improved range-request batching. |
| 3.40 / 3.44 | 3.12 | Identical usage; newer GDAL adds better multi-threaded reads for remote rasters. |
The virtual file system belongs to GDAL rather than QGIS, so behaviour tracks the GDAL version your QGIS was built against — check it with gdal.VersionInfo() when a documented option appears to be ignored.
Troubleshooting
- The layer is invalid over HTTPS but the URL works in a browser. The server does not support range requests, or a redirect is losing the range header. Test with
gdal.Info()for the real error. - Opening is slow but rendering is fast. GDAL is listing the directory or probing sidecars. Set
GDAL_DISABLE_READDIR_ON_OPENtoEMPTY_DIR. - Zooming out is slower than zooming in. The file has no internal overviews, so a whole-country view reads full-resolution pixels. It is not a real COG; rebuild it.
- Credentials are ignored. The environment variables must be set before the layer is opened, in the same process. Setting them in a shell that launched QGIS earlier does not help a script running later in a different session.
- Statistics take forever. Computing them reads the whole raster. Use
gdal.Info()with approximate statistics, or accept the layer's default rendering. - Processing writes a plain TIFF. Most algorithms do. Convert the output explicitly with the options above before publishing it.
Conclusion
Prefix the URL with /vsicurl/ — or /vsis3/, /vsiaz/, /vsigs/ — and a cloud-hosted raster becomes an ordinary GDAL layer. Set the three configuration options that stop GDAL wasting round trips, verify that the file is genuinely tiled with internal overviews before relying on it, and write your own outputs as COGs so the next person can do the same.
Frequently Asked Questions
Do I need to download the file first? No — that is the point. GDAL fetches only the byte ranges it needs, so a multi-gigabyte raster renders from a few hundred kilobytes.
Does this work for anything other than GeoTIFF?
Yes. The virtual file system is format-agnostic, so /vsicurl/ works with any GDAL-readable format; only GeoTIFF has the cloud-optimized layout that makes partial reads efficient.
Can Processing algorithms run against a remote raster? They can, and they will read whatever they need over the network. For anything that touches every pixel, copy it locally first — the transfer happens either way, and locally it happens once.
How do I read a COG inside a zip on a server?
Chain the prefixes: /vsizip//vsicurl/https://.../archive.zip/dem.tif. GDAL composes virtual file systems, which also covers /vsigzip/ and /vsitar/.
Is a COG worse than a plain GeoTIFF locally? No. It is a valid GeoTIFF that any reader opens normally; internal tiling and overviews usually make local rendering faster too.