Load a WCS Coverage in PyQGIS

A Web Map Service returns pictures: rendered, coloured images meant for looking at. A Web Coverage Service returns data: the actual elevation values, temperatures or classification codes, in their native data type, ready for analysis. National mapping agencies publish elevation models through WCS, meteorological services publish gridded forecasts, and environmental agencies publish land cover and soil grids. When a script needs the numbers rather than the picture, WCS is the protocol to use.

This recipe belongs to Web Services and Remote Data in PyQGIS. It discovers the coverages a server offers, loads one as a raster layer, chooses CRS, format and time, downloads a subset into a local GeoTIFF for analysis, and explains when WMS, WCS or a cloud-optimised file is the better source.

Pictures versus valuesA WMS request returns a rendered image: red, green and blue pixel colours chosen by the server's style, useful for display but not for computing slope or statistics. A WCS request returns the coverage values: for an elevation model, 32-bit floats in metres with NoData, so slope, profiles and zonal statistics give correct results. The same server often offers both.WMS for looking, WCS for computingWMSrendered RGB imageserver's coloursdisplay onlyslope from it = nonsenseWCSraw values, e.g. Float32metres, NoDataanalysis-readyslope, profiles, stats

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series.
  • The base URL of a WCS endpoint, for example https://geo.example.org/wcs. Opening …?SERVICE=WCS&REQUEST=GetCapabilities in a browser shows the offered coverages.
  • Credentials, if the service is protected, stored as an authentication configuration as in loading a WFS layer.

Discover coverages

The capabilities document lists each coverage with an identifier and title. QGIS's network classes fetch it with QGIS's proxy and authentication settings.

import xml.etree.ElementTree as ET
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.core import QgsBlockingNetworkRequest

BASE = "https://geo.example.org/wcs"
req = QNetworkRequest(QUrl(f"{BASE}?SERVICE=WCS&VERSION=2.0.1&REQUEST=GetCapabilities"))
blocking = QgsBlockingNetworkRequest()
if blocking.get(req) != QgsBlockingNetworkRequest.NoError:
    raise RuntimeError(blocking.errorMessage())
root = ET.fromstring(bytes(blocking.reply().content()))

ns = {"wcs": "http://www.opengis.net/wcs/2.0", "ows": "http://www.opengis.net/ows/2.0"}
for cs in root.iter("{http://www.opengis.net/wcs/2.0}CoverageSummary"):
    cid = cs.find("wcs:CoverageId", ns).text
    title = cs.find("ows:Title", ns)
    print(f"{cid:<36} {title.text if title is not None else ''}")

Breakdown: WCS 2.0 lists coverages as CoverageSummary elements with a CoverageId, the name the provider needs. Older servers speak WCS 1.0 or 1.1 with different element names; the QGIS provider handles all three, but a hand-written parser must match the version requested. QgsBlockingNetworkRequest is fine in scripts; inside plugins, use the asynchronous request classes to keep the interface responsive, as described in making HTTP requests with QgsNetworkAccessManager.

Load a coverage as a layer

The wcs provider takes a URI listing the server URL and coverage identifier, plus optional CRS, format and caching options.

from qgis.core import QgsDataSourceUri, QgsRasterLayer, QgsProject

uri = QgsDataSourceUri()
uri.setParam("url", BASE)
uri.setParam("identifier", "dem_1m")
uri.setParam("crs", "EPSG:25832")
uri.setParam("format", "image/tiff")
uri.setParam("cache", "PreferNetwork")

dem = QgsRasterLayer(str(uri.encodedUri(), "utf-8"), "DEM (WCS)", "wcs")
if not dem.isValid():
    raise RuntimeError(dem.error().summary())
prov = dem.dataProvider()
print(dem.bandCount(), "band(s);", prov.dataType(1), "; NoData:",
      prov.sourceNoDataValue(1) if prov.sourceHasNoDataValue(1) else None)
QgsProject.instance().addMapLayer(dem)

Breakdown: The WCS provider expects an encoded URI, which QgsDataSourceUri.encodedUri() produces; it returns bytes, hence the decode. identifier is the coverage id from the capabilities; crs asks for a CRS the server supports; format selects the transfer encoding — GeoTIFF preserves data type and NoData. Checking the band data type is a quick confirmation that real values arrived: a Float32 elevation band is data, a Byte RGB triplet would mean the server returned a rendered image. The layer requests data for the current view as you pan and zoom.

Choose CRS, format and time

Coverage servers can return data in several CRSs and formats, and many coverages have a time dimension — daily forecasts, monthly composites.

Request parameters that matterCRS: request the coverage's native CRS when possible to avoid server-side resampling. Format: GeoTIFF keeps data type and NoData; PNG or JPEG lose them. Time: for temporal coverages, a time parameter selects one slice. Resolution follows the request size, so ask for the native pixel size when downloading for analysis.CRS, format, time, resolutionCRSnative if possibleavoid resamplingformatimage/tiffkeeps valuestimeone slicefor forecastsresolutionnative pixelfor analysis

temporal = QgsDataSourceUri()
temporal.setParam("url", "https://met.example.org/wcs")
temporal.setParam("identifier", "temperature_2m")
temporal.setParam("crs", "EPSG:4326")
temporal.setParam("format", "image/tiff")
temporal.setParam("time", "2026-10-02T12:00:00Z")

t2m = QgsRasterLayer(str(temporal.encodedUri(), "utf-8"), "T2m 12:00", "wcs")
print(t2m.isValid(), t2m.dataProvider().dataType(1))

Breakdown: Requesting the coverage's native CRS avoids server-side resampling, which can blur values or change NoData handling. GeoTIFF is the format that reliably carries floats and NoData; PNG and JPEG are image formats and quantise values. For temporal coverages, the time parameter selects one slice in ISO 8601 form; loading several times means several layers, which can then be stepped through with the temporal controller. The capabilities and DescribeCoverage responses list supported CRSs, formats and time positions.

Download a subset for analysis

A WCS layer re-requests data whenever the view changes, which is wasteful for analysis and unreliable for long jobs. Downloading the area of interest once into a local GeoTIFF gives a stable input.

import processing
from qgis.core import QgsRectangle

area = QgsRectangle(565000, 5928000, 575000, 5938000)       # in EPSG:25832
local = processing.run("gdal:cliprasterbyextent", {
    "INPUT": dem,
    "PROJWIN": f"{area.xMinimum()},{area.xMaximum()},{area.yMinimum()},{area.yMaximum()} [EPSG:25832]",
    "NODATA": None,
    "OPTIONS": "COMPRESS=DEFLATE|PREDICTOR=3|TILED=YES",
    "DATA_TYPE": 0,
    "OUTPUT": "/data/cache/dem_1m_subset.tif",
})["OUTPUT"]

check = QgsRasterLayer(local, "DEM subset")
print(check.width(), "x", check.height(), "pixels at", round(check.rasterUnitsPerPixelX(), 2), "m")

Breakdown: Clipping the WCS layer by extent makes GDAL request exactly that window and write it to disk, keeping the native data type with DATA_TYPE 0. The pixel size printed afterwards should match the coverage's native resolution; if it is coarser, the request was resampled and should be repeated with an explicit resolution. Large areas may exceed a server's maximum response size — download in tiles and merge, as in merging raster tiles into a mosaic.

Download large areas in tiles

Servers cap the size of a single response — often a few thousand pixels on a side. For a large area at native resolution, request a grid of tiles, each within the limit, and assemble them locally.

Tiled downloadThe area of interest is divided into tiles small enough for the server's maximum response size, each snapped to the coverage's pixel grid. Each tile is downloaded as a GeoTIFF; failed tiles are retried. A VRT or merge assembles the tiles into one raster covering the whole area.Split, download, assemblearea of interesttoo big for onerequesttiles≤ server limitgrid-alignedretry failuresVRT / mergeone raster

import math
from pathlib import Path

TILE_PX = 2000
px = dem.rasterUnitsPerPixelX()
step = TILE_PX * px
tiles = []
for i in range(math.ceil(area.width() / step)):
    for j in range(math.ceil(area.height() / step)):
        x0 = area.xMinimum() + i * step
        y0 = area.yMinimum() + j * step
        x1, y1 = min(x0 + step, area.xMaximum()), min(y0 + step, area.yMaximum())
        out = f"/data/cache/dem_tiles/t_{i:03d}_{j:03d}.tif"
        if not Path(out).exists():
            processing.run("gdal:cliprasterbyextent", {
                "INPUT": dem, "PROJWIN": f"{x0},{x1},{y0},{y1} [EPSG:25832]",
                "DATA_TYPE": 0, "OPTIONS": "COMPRESS=DEFLATE|TILED=YES", "OUTPUT": out})
        tiles.append(out)
vrt = processing.run("gdal:buildvirtualraster", {
    "INPUT": tiles, "SEPARATE": False, "OUTPUT": "/data/cache/dem_area.vrt"})["OUTPUT"]

Breakdown: Tiles of 2,000 pixels at native resolution stay under typical limits; check the server's documentation or its MaxWidth/MaxHeight hints. Skipping tiles that already exist makes the loop restartable after a network failure — rerun it and only missing tiles are fetched. Starting tiles at the area's origin and stepping by whole pixels keeps them aligned with each other. A VRT assembles the result without copying, as in building a virtual raster; translate it to one GeoTIFF if a single file is needed.

Record where the data came from

Downloaded coverages lose their link to the server. Writing the source, coverage id and request time into the file's metadata keeps the provenance.

from osgeo import gdal
from datetime import datetime, timezone

ds = gdal.Open(local, gdal.GA_Update)
ds.SetMetadata({"SOURCE_SERVICE": BASE, "COVERAGE_ID": "dem_1m",
                "RETRIEVED_UTC": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")})
ds = None
print(gdal.Info(local).splitlines()[0])

Breakdown: GeoTIFF metadata items travel with the file and appear in QGIS's layer properties and in gdalinfo. Recording the retrieval time matters most for coverages that change — forecasts, near-real-time land cover — where "the DEM from the WCS" is ambiguous without a date. The same habit is shown for vector services in loading an OGC API Features layer.

WCS, WMS or a COG?

Use WMS when you only need to see the data. Use WCS when you need values and the provider publishes through it. Increasingly, providers publish raster data as Cloud Optimized GeoTIFFs on object storage instead — simpler for them, faster for you, and readable with the same GDAL machinery; see reading a Cloud Optimized GeoTIFF. Where both WCS and COG are offered, COG is usually the more reliable choice for scripted downloads.

QGIS version compatibility

The wcs provider supports WCS 1.0, 1.1 and 2.0 servers on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. URI parameters such as identifier, crs, format, time and cache are stable across these releases. On QGIS 4, QgsBlockingNetworkRequest.NoError is QgsBlockingNetworkRequest.ErrorCode.NoError.

Troubleshooting

  • The layer is invalid. The identifier is the title rather than the CoverageId, or the server only speaks an older version; try version=1.0.0 in the URI.
  • Values look like colours (0–255). The server returned a rendered image; request image/tiff and check the coverage is data, not imagery.
  • Downloads fail for large areas. The server limits response size; download in tiles.
  • NoData appears as a real value. The format dropped NoData; use GeoTIFF and set NoData explicitly if needed.

Conclusion

Discover coverage ids from GetCapabilities, load them with the wcs provider in their native CRS and GeoTIFF format, select a time slice for temporal coverages, download the area of interest once into a compressed local GeoTIFF for analysis, record provenance in its metadata, and prefer COG sources where providers offer them.

Frequently Asked Questions

Can I compute slope directly on a WCS layer? Yes, but download first: analysis on a live service is slower and can fail mid-run.

Does QGIS cache WCS responses? Yes, through its network cache, controlled by the cache URI parameter and QGIS's cache settings.

What is DescribeCoverage? A request returning one coverage's details — grid, CRSs, range type — useful for choosing resolution and format.

Is WCS being replaced? OGC API – Coverages is its successor; adoption is still early, and WCS remains widely deployed.