The OGC Web Coverage Service defines an HTTP contract for retrieving coverage data — the raw, multi-dimensional grids behind a map, not a picture of them. Where a rendered image answers “what does this area look like”, a coverage answers “what are the actual values here”: the metres of elevation in each cell, the radiance in each spectral band, the temperature at each timestep. For GIS platform engineers, spatial data publishers, and Python backend developers building analytical pipelines, WCS is the protocol that lets a client pull a georeferenced, numerically exact subset of a raster archive over the wire without downloading the whole dataset. This page covers the three core WCS operations, the WCS 2.0.1 core plus its key extensions, axis and range subsetting, a complete rasterio implementation, error handling, compliance testing, and performance hardening.
Before consuming or serving WCS endpoints, engineering teams should be comfortable with:
uint16, float32) as they survive a round trip through a format like GeoTIFF or netCDFrequests (HTTP), rasterio/GDAL (reading the returned coverage), numpy (array analysis), and lxml (validating the XML capabilities and coverage descriptions)WCS is one member of the interoperable protocol family described in OGC Standards Architecture & Service Fundamentals. It is the data-retrieval counterpart to the rendering-focused Understanding OGC Web Map Service Specifications: the same underlying raster can be exposed both as a WMS layer for display and a WCS coverage for download, and mature servers frequently publish both from one datastore. Knowing which protocol a given client actually needs — pixels to look at, or values to compute on — prevents shipping a styling pipeline where an analytical one was required.
WCS defines three operations. The client discovers a service, describes a specific coverage’s structure, then retrieves a subset of it. Each step feeds the next: you cannot build a correct GetCoverage request without first reading the axis labels and band names from DescribeCoverage.
GetCapabilities returns an XML document (root element wcs:Capabilities in WCS 2.0.1) describing service metadata, the WCS versions and extensions the server implements, the supported output formats and CRSs, and — critically — a Contents section listing every published coverage by its CoverageId. Clients parse this to discover what can be fetched and which capabilities (scaling, range subsetting, interpolation) the server advertises via its ServiceMetadata profiles.
Mandatory request parameters:
| Parameter | Required | Notes |
|---|---|---|
SERVICE |
Yes | Always WCS |
VERSION |
No | Omit for negotiation; 2.0.1 is the common core |
REQUEST |
Yes | GetCapabilities |
The Contents list can grow to thousands of CoverageId entries on an archive server. Fetch and cache the capabilities document rather than re-requesting it per download, and read the advertised profile URIs to confirm an extension (for example the Range Subsetting extension) is actually supported before relying on it.
DescribeCoverage returns a CoverageDescription for one or more CoverageId values. This is the operation clients most often skip and most often should not: it declares the coverage’s domainSet (the axes — spatial, temporal, spectral — and their extents and resolutions) and its rangeType (a SWE Common DataRecord naming each band, its data type, unit of measure, and nodata value). Every legal subset expression must reference axis labels and range-component names taken from this document.
Mandatory request parameters:
| Parameter | Required | Notes |
|---|---|---|
SERVICE |
Yes | WCS |
VERSION |
Yes | 2.0.1 |
REQUEST |
Yes | DescribeCoverage |
COVERAGEID |
Yes | One or more IDs, comma-separated |
A coverage’s axis labels are not universal. A 2D geographic grid in EPSG:4326 typically exposes Lat and Long; a projected grid may expose X and Y; a datacube may add time and a spectral bands axis. Reading these labels from DescribeCoverage is the only reliable way to construct a valid subset clause.
GetCoverage is the core operation: it returns the coverage — or, in practice, a subset of it — encoded in a requested format. The server extracts the requested region and bands, optionally reprojects and rescales, and streams back a GeoTIFF, netCDF, or other coverage encoding. Unlike WMS GetMap, the response carries the original data values, not a rendered picture.
| Parameter | Required | Notes |
|---|---|---|
SERVICE |
Yes | WCS |
VERSION |
Yes | 2.0.1 |
REQUEST |
Yes | GetCoverage |
COVERAGEID |
Yes | Exactly one coverage |
SUBSET |
No | Repeatable — one per axis, e.g. subset=Long(-0.5,0.5) |
RANGESUBSET |
No | Range Subsetting extension — select/reorder bands |
SCALEFACTOR / SCALESIZE |
No | Scaling extension — decimate resolution |
SUBSETTINGCRS |
No | CRS Extension — CRS in which subset coordinates are given |
OUTPUTCRS |
No | CRS Extension — reproject the output |
INTERPOLATION |
No | Interpolation extension — nearest, linear, cubic |
FORMAT |
Yes | MIME type, e.g. image/tiff or application/x-netcdf |
This operation is where server-side subsetting earns its keep. Requesting a 200 by 200 km trim of a national elevation model returns a few megabytes; requesting the whole coverage might return tens of gigabytes. Production clients always constrain the spatial axes, and often the range and resolution, before the server touches the raster.
Subsetting is the heart of WCS. There are two operations on the domain axes and one on the range (the bands).
Trim keeps an interval on an axis and preserves that axis in the output. subset=Lat(50.0,52.0) returns every row whose latitude falls between the two bounds; the latitude axis still exists in the result, just narrower. Trims are how you crop a spatial window: pair subset=Long(-0.5,0.5) with subset=Lat(51.2,51.7) to extract central London from a continental coverage.
Slice pins an axis to a single value and removes it, reducing the coverage’s dimensionality. subset=time("2026-01-01T00:00:00Z") collapses a 3D space-time datacube to a single 2D layer. Slicing a multi-band spectral axis to one wavelength yields a single-band raster. The distinction matters for clients: a trim of time returns a stack you must iterate; a slice of time returns one array you can read directly.
Multi-dimensional coverages combine these. A Sentinel-style archive might expose a coverage with Lat, Long, time, and a spectral bands axis. To pull the near-infrared band for one date over a city, you trim the two spatial axes, slice time, and slice or range-subset the spectral axis.
Range subsetting operates on the bands, not the geometry. The Range Subsetting extension adds rangeSubset=red,nir to select and reorder named range components from the coverage’s rangeType. On a 13-band coverage this alone cuts the payload by an order of magnitude, and it is composable with axis subsetting — you trim the geometry and range-subset the bands in the same request.
Output encodings determine the container. format=image/tiff returns a GeoTIFF, ideal for single- or few-band spatial extracts that GDAL and rasterio read natively. format=application/x-netcdf returns netCDF, the better choice for multi-dimensional extracts (time series, many spectral bands) where a self-describing dimensional container beats a flat raster. The format governs how the axes you preserved through trimming and slicing are laid out on disk.
The client below issues all three operations against a WCS 2.0.1 endpoint: it discovers a coverage, describes it, then downloads a spatially trimmed, band-subsetted GeoTIFF and loads it into a numpy array with rasterio. It is self-contained and pip-installable.
"""
wcs_client.py — minimal OGC WCS 2.0.1 GetCoverage client
Dependencies (pip install):
requests>=2.31, rasterio>=1.3, numpy>=1.26, lxml>=5.0
"""
from __future__ import annotations
import io
from dataclasses import dataclass
import numpy as np
import rasterio
import requests
from lxml import etree
from rasterio.io import MemoryFile
WCS_NS = {
"wcs": "http://www.opengis.net/wcs/2.0",
"gml": "http://www.opengis.net/gml/3.2",
"ows": "http://www.opengis.net/ows/2.0",
}
MAX_BYTES = 256 * 1024 * 1024 # refuse to buffer coverages larger than 256 MB
class ServiceException(RuntimeError):
"""Raised when the WCS server returns an ows:ExceptionReport."""
@dataclass
class CoverageArray:
data: np.ndarray # (bands, rows, cols)
transform: rasterio.Affine
crs: str
nodata: float | None
band_names: list[str]
def _check_for_exception(content_type: str, body: bytes) -> None:
"""WCS faults arrive as 200/400 XML, not always as an HTTP error code."""
if "xml" not in content_type.lower():
return
try:
root = etree.fromstring(body)
except etree.XMLSyntaxError:
return
if root.tag.endswith("ExceptionReport"):
texts = root.xpath("//ows:ExceptionText/text()", namespaces=WCS_NS)
raise ServiceException("; ".join(t.strip() for t in texts) or "unknown WCS fault")
def describe_coverage(base_url: str, coverage_id: str,
session: requests.Session) -> list[str]:
"""Return the ordered range-component (band) names from DescribeCoverage."""
params = {
"SERVICE": "WCS",
"VERSION": "2.0.1",
"REQUEST": "DescribeCoverage",
"COVERAGEID": coverage_id,
}
resp = session.get(base_url, params=params, timeout=30)
resp.raise_for_status()
_check_for_exception(resp.headers.get("Content-Type", ""), resp.content)
root = etree.fromstring(resp.content)
# SWE range components carry the band names under swe:field/@name
names = root.xpath("//*[local-name()='field']/@name")
return [str(n) for n in names]
def get_coverage(
base_url: str,
coverage_id: str,
*,
lon: tuple[float, float],
lat: tuple[float, float],
bands: list[str] | None = None,
subsetting_crs: str = "http://www.opengis.net/def/crs/EPSG/0/4326",
output_format: str = "image/tiff",
session: requests.Session | None = None,
) -> CoverageArray:
"""
Download a spatially trimmed, optionally band-subsetted coverage and
load it into a numpy array via rasterio.
"""
session = session or requests.Session()
# WCS 2.0.1 requires repeated SUBSET params; requests serialises a list
# of (key, value) tuples with duplicate keys preserved in order.
query: list[tuple[str, str]] = [
("SERVICE", "WCS"),
("VERSION", "2.0.1"),
("REQUEST", "GetCoverage"),
("COVERAGEID", coverage_id),
("FORMAT", output_format),
("SUBSETTINGCRS", subsetting_crs),
# Axis labels MUST match the coverage's domainSet (Long/Lat here).
("SUBSET", f"Long({lon[0]},{lon[1]})"),
("SUBSET", f"Lat({lat[0]},{lat[1]})"),
]
if bands:
query.append(("RANGESUBSET", ",".join(bands)))
resp = session.get(base_url, params=query, timeout=120, stream=False)
resp.raise_for_status()
_check_for_exception(resp.headers.get("Content-Type", ""), resp.content)
if len(resp.content) > MAX_BYTES:
raise ServiceException(
f"coverage response {len(resp.content)} bytes exceeds "
f"{MAX_BYTES} byte guard; subset more aggressively"
)
# Read the returned GeoTIFF from memory without touching disk.
with MemoryFile(resp.content) as memfile:
with memfile.open() as dataset:
data = dataset.read() # (bands, rows, cols)
return CoverageArray(
data=data,
transform=dataset.transform,
crs=str(dataset.crs),
nodata=dataset.nodata,
band_names=bands or [f"band_{i}" for i in range(1, data.shape[0] + 1)],
)
if __name__ == "__main__":
ENDPOINT = "https://ows.example.org/wcs"
with requests.Session() as s:
available = describe_coverage(ENDPOINT, "srtm_elevation", session=s)
print("range components:", available)
cov = get_coverage(
ENDPOINT,
"srtm_elevation",
lon=(-0.5, 0.3),
lat=(51.2, 51.7),
bands=["elevation"] if "elevation" in available else None,
session=s,
)
arr = cov.data.astype("float32")
if cov.nodata is not None:
arr = np.where(arr == cov.nodata, np.nan, arr)
print("shape:", cov.data.shape, "crs:", cov.crs)
print("min/max elevation:", np.nanmin(arr), np.nanmax(arr))
Fault detection (_check_for_exception) — A WCS server signals errors with an ows:ExceptionReport XML body, and not every server sets a 4xx HTTP status when it does — some return the report with a 200. The helper sniffs the Content-Type, parses any XML body, and raises ServiceException with the concatenated ExceptionText values. Relying on raise_for_status alone would let a fault masquerade as a successful, corrupt download.
Reading band names (describe_coverage) — The function pulls range-component names from the swe:field/@name attributes of the DescribeCoverage response using a local-name() XPath, which sidesteps the namespace-prefix drift between SWE Common versions. These names are exactly the tokens the Range Subsetting extension expects in rangeSubset, so describing before downloading guarantees the band selector is valid.
Duplicate SUBSET keys (get_coverage) — WCS 2.0.1 KVP encoding requires one SUBSET parameter per axis, which means the query string legitimately contains SUBSET twice. Passing requests a list of (key, value) tuples rather than a dict preserves both keys and their order; a dict would silently collapse them to one. The axis labels Long and Lat must match the coverage’s declared domainSet, which is why the earlier DescribeCoverage step matters.
In-memory GeoTIFF (MemoryFile) — rasterio.io.MemoryFile opens the response bytes as a GDAL dataset without a temporary file, so the affine transform, crs, and nodata come straight from the returned raster’s own header rather than being reconstructed. The read yields a (bands, rows, cols) array in band-first order, matching GDAL’s convention.
Nodata handling — The __main__ block promotes the array to float32 and replaces the coverage’s declared nodata sentinel with np.nan before computing statistics. Skipping this step is the most common source of nonsensical minima (a nodata value of -32768 dragging the reported minimum elevation below sea level).
InvalidAxisLabel — wrong subset axis name — Supplying subset=X(...) against a coverage whose domain uses Long yields an ows:ExceptionReport with exception code InvalidAxisLabel. Always take axis labels from DescribeCoverage; do not assume X/Y versus Long/Lat.
InvalidSubsetting — bounds outside the envelope or inverted — A trim whose lower bound exceeds its upper bound, or whose interval falls entirely outside the coverage’s advertised envelope, raises InvalidSubsetting. Clamp your requested window to the boundedBy envelope read from DescribeCoverage before sending, and verify lower < upper on each axis.
Axis-order and CRS mismatches — Because WCS 2.0.1 honours the CRS’s native axis order, a subset expressed with easting-first coordinates against a latitude-first SUBSETTINGCRS extracts the wrong region without erroring — the same class of silent failure that afflicts map requests. The Handling Spatial Reference Mismatches in OGC Requests guide covers diagnosing these transposed extracts.
Oversized coverage requests — A GetCoverage with no spatial subset against a national or global archive can attempt to serialise tens of gigabytes. Servers such as rasdaman and MapServer enforce a maximum output size and return a ServiceException (often code RequestTooLarge or a size limit message) rather than streaming it. The client’s MAX_BYTES guard is a second line of defence: fail loudly before buffering an unexpected multi-gigabyte body into memory.
Format not offered — Requesting format=application/x-netcdf from a server that advertises only image/tiff in its capabilities returns a fault. Read the supported formatSupported values from GetCapabilities and fall back to GeoTIFF when netCDF is unavailable.
Interpolation surprises on reprojection — When OUTPUTCRS differs from the coverage’s native CRS, the server resamples, and the default interpolation may be nearest-neighbour. For continuous fields such as elevation, explicitly request interpolation=linear (or cubic) via the Interpolation extension, or accept the blocky artefacts nearest-neighbour introduces.
Validate the structure of a DescribeCoverage response against the official WCS XSD, and unit-test the request builder so subset clauses are well-formed before they ever hit the network.
"""
test_wcs_client.py — validation + unit tests for the WCS client
"""
import pytest
import requests
from lxml import etree
from wcs_client import ServiceException, _check_for_exception
class TestExceptionDetection:
def test_exception_report_raises(self):
body = (
b'<?xml version="1.0"?>'
b'<ows:ExceptionReport xmlns:ows="http://www.opengis.net/ows/2.0">'
b'<ows:Exception exceptionCode="InvalidAxisLabel">'
b'<ows:ExceptionText>Unknown axis: X</ows:ExceptionText>'
b'</ows:Exception></ows:ExceptionReport>'
)
with pytest.raises(ServiceException, match="Unknown axis"):
_check_for_exception("application/xml", body)
def test_geotiff_body_is_not_treated_as_fault(self):
# A binary TIFF must never be parsed as an exception report.
_check_for_exception("image/tiff", b"II*\x00random-binary")
def test_describe_coverage_schema_valid():
"""Schema-validate a DescribeCoverage response against the cached WCS XSD.
Fetch coverageDescription.xsd once from schemas.opengis.net and cache it
locally so CI does not depend on network access to an external host.
"""
schema_doc = etree.parse("schemas/wcs/2.0/wcsAll.xsd")
schema = etree.XMLSchema(schema_doc)
describe_doc = etree.parse("fixtures/describe_srtm_elevation.xml")
# Raises DocumentInvalid with a precise line/column on failure.
schema.assertValid(describe_doc)
def test_subset_axis_labels_match_domain():
"""Guard: the client must emit Long/Lat, never X/Y, for an EPSG:4326 grid."""
from wcs_client import get_coverage # builds the query list internally
# In practice, assert against a recorded request via responses/requests-mock.
assert "Long" != "X" # placeholder for a mocked-request assertion
For formal compliance, run the OGC CITE (Compliance + Interoperability Testing + Evaluation) WCS 2.0 test suite against your endpoint. CITE exercises all three operations, the exception-report behaviour, and the subsetting extensions, and confirms your capabilities document accurately advertises the profiles you implement. Cache the OGC WCS XSDs from schemas.opengis.net into your repository so lxml.etree.XMLSchema validation in CI never reaches out to an external URL — the same offline-schema discipline applies here as for map-service capabilities validation.
Subset server-side, always — The single largest performance lever is never downloading data you will discard. Every trim, slice, and rangeSubset shifts filtering to the server, where it runs against an indexed store, and shrinks the payload before it crosses the network. A client that downloads a whole coverage and crops locally has already lost.
Cloud-Optimized GeoTIFF backing stores — When the coverage’s source rasters are Cloud-Optimized GeoTIFFs (COGs), the server can honour a spatial subset by reading only the relevant internal tiles and overviews via HTTP range requests, rather than scanning the whole file. Pairing WCS with a COG archive turns a spatial trim into a handful of byte-range reads. This is the natural bridge to raster catalogue publishing; the STAC and COG patterns for organising such archives are a metadata-side concern beyond this page.
Scaling extension for previews — When a client needs an overview rather than full resolution, scaleSize or scaleFactor instructs the server to decimate before encoding. Fetching a 512 by 512 preview of a 40000 by 40000 coverage with scaleSize=Long(512),Lat(512) returns kilobytes instead of gigabytes and offloads the resampling to the server.
Request size limits and back-pressure — Configure and document a maximum output size on the server, and have clients degrade gracefully: catch the RequestTooLarge fault, halve the requested extent or apply a scale factor, and retry. Enforcing a ceiling protects the server from a single unbounded request exhausting memory while it serialises the response.
Connection reuse — A harvesting client that pulls hundreds of tiles from one endpoint should hold a single requests.Session with a pooled HTTPAdapter (pool_maxsize sized to its concurrency) so TLS handshakes and capabilities lookups are amortised across the run rather than repeated per coverage.
A WMS GetMap response is a rendered picture — pixels already styled with a colour ramp for a human to look at, with the underlying data values discarded during symbolisation. A WCS GetCoverage response is the data itself: the original elevation, radiance, or temperature values in their native data type (float32, uint16), georeferenced and ready for computation. If you need to display a layer, request WMS; if you need to run band maths, extract statistics, or feed a model, request WCS. The same raster datastore commonly exposes both, as covered in the Understanding OGC Web Map Service Specifications guide.
A trim keeps an interval on an axis and preserves that axis in the output: subset=Lat(50,52) returns every row between the two latitudes, and the latitude axis still exists in the result. A slice pins an axis to a single value and removes it: subset=time("2026-01-01") collapses the time axis, reducing a 3D space-time coverage to a 2D layer. Trims narrow an axis; slices reduce the coverage’s dimensionality. You typically trim the two spatial axes and slice time to pull one date’s grid over a region.
Use the WCS Range Subsetting extension. Add rangeSubset=red,nir to a GetCoverage request to return only those two range components, named exactly as they appear in the rangeType SWE DataRecord of the DescribeCoverage response. Range subsetting is independent of axis subsetting, so you can trim the geometry and select bands in the same request. On a 13-band coverage this cuts payload size by an order of magnitude. Confirm the server advertises the Range Subsetting profile in its GetCapabilities before relying on it.
WCS 2.0.1 honours the CRS’s native axis order, so a subset on EPSG:4326 uses Lat and Long axis labels rather than generic X and Y, and those labels must match the coverage’s declared domainSet. Mislabelling Long as Lat, or supplying easting-first coordinates against a latitude-first SUBSETTINGCRS, extracts the wrong region — often an empty or transposed array — without raising an error. Read the axis labels from DescribeCoverage rather than guessing, and see the SRS and Coordinate Reference System Handling guide for the axis-order rules.
Always subset server-side on the spatial axes before download, use rangeSubset to drop unneeded bands, and apply the Scaling extension (scaleSize or scaleFactor) when you only need an overview resolution. Servers such as rasdaman and MapServer enforce a maximum output size and return a ServiceException when a request would exceed it, so check for that fault and back off — halve the extent or add a scale factor — rather than assuming success. A client-side byte guard is a sensible second line of defence against buffering an unexpectedly large response.
Back to OGC Standards Architecture & Service Fundamentals
Related: