Web Services and Remote Data in PyQGIS

Not every dataset is a file on your disk, and increasingly the interesting ones never will be. National mapping agencies publish base maps as tile services, environment agencies publish flood extents as WFS, satellite archives publish imagery as cloud-optimized GeoTIFFs in object storage, and all three are loadable from PyQGIS in a line or two — as long as the URI is built correctly, which is where almost all the difficulty lives.

This guide sits inside Spatial Data Processing & Automation and covers the remote end of loading data. It is the counterpart to Vector Data Manipulation in PyQGIS and PostGIS and Database Workflows in PyQGIS: the same layer objects, the same algorithms, but with a network in the middle that can be slow, rate-limited, or simply down when your scheduled job runs at three in the morning.

Four kinds of remote source, four providers, one layer APIA WMS service returns rendered images through the wms provider. An XYZ tile service also uses the wms provider with a type equals xyz URI. A WFS service returns actual vector features through the WFS provider. A cloud-optimized GeoTIFF in object storage is read through GDAL's virtual file system by the gdal provider. All four arrive as ordinary QGIS layers.What comes back over the wire decides what you can do with itWMSrendered imageprovider: wmsXYZ tilespre-rendered tilesprovider: wms, type=xyzWFSreal featuresprovider: WFScloud rasterpixels by byte rangeprovider: gdal, vsicurlQgsRasterLayer / QgsVectorLayerstyling, Processing and layouts work as usualAn image service cannot be queried, joined or analysed — only WFS and raster pixels carry data

Pictures or data: the distinction that decides everything

The first question about any web service is whether it sends you data or a picture of data, because that determines what your script can do with it.

A WMS or an XYZ tile service returns rendered images. The styling was decided on the server, the layer has no attribute table, and no Processing algorithm can do anything meaningful with it beyond clipping the pixels. That is perfectly fine when the layer is context — a topographic base map underneath your analysis — and useless when you need to know which parcels intersect the flood zone.

A WFS returns actual geometry and attributes. The layer behaves like a vector layer read from a file: you can iterate features, run a spatial join, style it yourself and export it. The catch is that every feature crosses the network, so the filtering discipline that is merely good practice against a local GeoPackage is mandatory here.

A cloud-hosted raster — typically a cloud-optimized GeoTIFF — is data too, and the cleverest of the four. GDAL reads it with HTTP range requests, fetching only the tiles and overview level the current view needs rather than the whole file. A 40 GB national elevation model can be added to a project in a second and rendered from a few hundred kilobytes of traffic.

The practical rule: use image services for background, WFS for features you need to reason about, and cloud rasters wherever the alternative is downloading gigabytes you will use a fraction of.

Building the URI

Every remote layer is described by a URI string, and every provider parses it differently. Assembling those strings by hand is where the afternoons go, so let the API do it.

from qgis.core import QgsDataSourceUri, QgsRasterLayer

uri = QgsDataSourceUri()
uri.setParam("url", "https://maps.example.org/geoserver/wms")
uri.setParam("layers", "topo:contours")
uri.setParam("styles", "")
uri.setParam("format", "image/png")
uri.setParam("crs", "EPSG:27700")

layer = QgsRasterLayer(str(uri.encodedUri(), "utf-8"), "Contours", "wms")
print(layer.isValid())

Breakdown: setParam() escapes values for you, which matters as soon as a layer name contains a colon or a URL carries its own query string — the reason hand-built strings fail is almost always an unescaped character rather than a wrong parameter. encodedUri() returns bytes, hence the decode. Passing "wms" as the provider is what routes the string to the WMS parser; the same string handed to "gdal" produces an invalid layer with no useful message. Always check isValid() immediately: a remote layer that failed to load looks identical to one that has not finished loading yet.

For XYZ tiles the same provider takes a different parameter set, and the URL template is the whole configuration:

uri = ("type=xyz&url=https://tiles.example.org/{z}/{x}/{y}.png"
       "&zmin=0&zmax=19")
basemap = QgsRasterLayer(uri, "Base map", "wms")

Breakdown: type=xyz switches the WMS provider into tile mode. The {z}/{x}/{y} placeholders are substituted per tile, and the zoom bounds prevent QGIS from requesting levels the service does not have — omitting them produces a wall of 404s and a slow, half-blank map. Any ampersand inside the URL itself must be percent-encoded, or it terminates the parameter early. Full details, including retina tiles and attribution, are in Add an XYZ Tile Basemap in PyQGIS.

Three URIs, three grammarsA WMS URI carries url, layers, format, styles and crs parameters. An XYZ URI carries a type of xyz, a templated tile url and zoom limits. A WFS URI carries a service url, a typename and optionally a filter and a bounding box restriction. Only the service address is common to all three.The provider decides which words the URI may containwmsurl — the service endpointlayers — one or manystyles — usually emptyformat — image/pngcrs — server must offer itreturns a picturewms with type=xyztype — xyzurl — templated by z, x, yzmin and zmaxno capabilities requestalways web mercatorreturns a pictureWFSurl — the service endpointtypename — the feature typeversion — 1.1.0 or 2.0.0filter — server-siderestrictToRequestBBOXreturns features

Ask the server what it offers

Rather than guessing layer names, ask. Both WMS and WFS advertise a capabilities document, and QGIS parses it for you through the provider metadata connection API — the same mechanism used for databases.

from qgis.core import QgsProviderRegistry

metadata = QgsProviderRegistry.instance().providerMetadata("WFS")
connection = metadata.createConnection(
    "https://data.example.org/geoserver/wfs?version=2.0.0", {})

for table in connection.tables():
    print(table.tableName(), table.geometryColumnTypes())

Breakdown: createConnection() with a URL and an empty options dictionary builds a temporary connection that is not saved into the user's settings — the right choice inside a script. tables() returns the advertised feature types, so a script can discover what a service publishes instead of hard-coding names that change at the next release. The same call against the wms provider lists layers and their supported coordinate systems, which is how you find out whether the service can deliver your project CRS or whether QGIS will be reprojecting images client-side.

Filtering on the server, not after

The single biggest performance lever for a WFS layer is refusing to download features you will not use. Two mechanisms do it, and they compose.

uri = ("https://data.example.org/geoserver/wfs"
       "?service=WFS&version=2.0.0&request=GetFeature"
       "&typename=env:flood_zones"
       "&srsname=EPSG:27700")

layer = QgsVectorLayer(f"{uri} restrictToRequestBBOX='1'", "Flood zones", "WFS")
layer.setSubsetString("risk_band = 'high'")

Breakdown: restrictToRequestBBOX='1' tells the provider to send the current view's bounding box with each request, so panning to a town fetches that town rather than the country. The subset string becomes a server-side filter expressed in the service's own filter language, meaning the rows never leave the server. Without either, the first render of a national dataset downloads the national dataset — and does it again on the next QGIS restart. The mechanics, including OGC filter encoding and the paging behaviour of large responses, are covered in Load a WFS Layer in PyQGIS.

The same instinct applies to cloud rasters, where the equivalent lever is asking for a coarse overview when you are rendering a whole country and full resolution only when zoomed in — which GDAL does automatically for a properly built cloud-optimized GeoTIFF, and cannot do at all for a plain one. That difference is the entire point of the format, and it is worked through in Read a Cloud-Optimized GeoTIFF in PyQGIS.

The same layer, with and without server-side filteringWithout restrictions the provider requests every feature in the service and receives hundreds of thousands of geometries over the network before anything renders. With a bounding box restriction and an attribute filter the request returns only the high risk zones intersecting the current view, a few hundred features, and renders immediately.Two parameters, two orders of magnitudeno restrictionsGetFeature — every feature type row412 000 features over the wireminutes before the first pixeland again after every restartbbox plus attribute filterbbox of the view plus risk band filter380 features over the wirerenders as fast as a local fileand the server stays friendly

The network is a dependency

A local file either exists or does not. A service can be slow, throttled, temporarily broken, behind a proxy, or fine for you and blocked for the server your job runs on. Scripts that treat remote layers like files eventually produce a map with a missing background and no explanation.

Set a timeout you can live with. QGIS's network timeout applies to every provider request, and the default is generous enough that a hung service can stall a batch job for a long time.

from qgis.core import QgsSettings

settings = QgsSettings()
settings.setValue("qgis/networkAndProxy/networkTimeout", 15000)   # milliseconds

Breakdown: Fifteen seconds is usually the right order of magnitude for an unattended job: long enough for a slow first response, short enough that a dead endpoint fails within the run rather than at the end of it. This is an application-scope setting, so it belongs in the job's startup code rather than in the project, following the scope split described in Working with QGIS Projects in PyQGIS.

Check validity, and say which layer failed. A remote layer that fails is still a layer object, and a job that adds it anyway silently produces a map with a hole.

if not layer.isValid():
    raise RuntimeError(f"{layer.name()} unavailable: {layer.error().summary()}")

Breakdown: The provider's own error text distinguishes the cases that matter — a 401 means credentials, a 404 means the layer name changed, a timeout means the service is struggling — and putting it in the exception message means the log tells you which of the three happened without a reproduction attempt.

Cache deliberately. QGIS keeps a network cache whose size and lifetime are settings; for tiles that rarely change, raising the cache size turns a repeated render into a local read. For a nightly job, the better answer is usually different: fetch once, write the result to a GeoPackage, and let the rest of the pipeline read the file.

Authenticate through the authentication database. Services requiring a key or a login should use a stored authentication configuration referenced by id, exactly as database connections do, rather than a token pasted into the URI where it will be saved into the project file and shared with it.

Fetch once, then work locally

Remote sources are excellent for interactive maps and background context. For repeated analysis they are usually the wrong shape, because every algorithm run re-fetches what has not changed. The pattern that solves it is a cache step at the top of the pipeline.

import processing
from qgis.core import QgsVectorLayer, QgsVectorFileWriter, QgsCoordinateTransformContext

remote = QgsVectorLayer(wfs_uri, "Flood zones", "WFS")
if not remote.isValid():
    raise RuntimeError("WFS unavailable")

options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "GPKG"
options.layerName = "flood_zones"
options.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer

error, message, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
    remote, "/data/cache/remote.gpkg", QgsCoordinateTransformContext(), options)
if error != QgsVectorFileWriter.NoError:
    raise RuntimeError(message)

Breakdown: One network read produces a local GeoPackage that every later step reads instantly and identically, which also makes the run reproducible — a rerun analyses the same data rather than whatever the service published in the meantime. CreateOrOverwriteLayer replaces this layer inside the container while leaving other cached layers alone. Recording when the cache was fetched, as a project variable or in the layer metadata, is what stops somebody analysing a six-month-old snapshot without realising.

The decision is not subtle. If the data changes slower than your analysis runs, cache it. If it changes faster, or you only ever look at a small window of it, read it live.

Newer services, same principles

The OGC standards did not stop at WMS and WFS, and QGIS speaks the newer ones too. The mechanics differ; the questions do not.

OGC API — Features (formerly WFS 3) is a JSON-and-links reworking of WFS that behaves much better over the modern web: pages of GeoJSON, ordinary HTTP caching, and no capabilities document to parse. QGIS loads it through the OAPIF provider, and everything above about server-side filtering applies unchanged — the parameters are spelled differently, and the discipline is identical.

Vector tiles are the tiled equivalent of a vector layer: geometry, generalised per zoom level, delivered as small binary tiles. They render beautifully and scale to a continent, but the features they carry are simplified for display, so they are for cartography rather than measurement. Load them through the vectortile provider with a URL template exactly like an XYZ source.

WCS returns raster values rather than a rendered picture, which makes it the analytical counterpart of WMS — worth looking for when a service offers both and you need numbers rather than colours.

Object storage — a bucket of cloud-optimized GeoTIFFs or GeoParquet files — is increasingly the way large archives are published. GDAL reaches it through virtual file system prefixes: /vsicurl/ for plain HTTP, /vsis3/ for S3, /vsiaz/ for Azure Blob Storage, each taking credentials from the usual environment variables.

from qgis.core import QgsRasterLayer

url = "/vsicurl/https://storage.example.org/dem/national_dem_cog.tif"
dem = QgsRasterLayer(url, "National DEM", "gdal")
print(dem.isValid(), dem.width(), dem.height())

Breakdown: The /vsicurl/ prefix tells GDAL to read the file over HTTP with range requests instead of downloading it. Nothing else changes: it is a gdal layer with a normal extent, band count and statistics, and every raster algorithm accepts it. The width and height printed here come from the file header alone, which is typically a single request of a few kilobytes against a file of many gigabytes.

Whichever of these a service speaks, the decision tree is the same one: is it a picture or data, can the server filter, and does the data change more or less often than the analysis runs.

Making remote layers behave in unattended jobs

A remote layer that renders happily on your screen can misbehave in a script, because interactive QGIS quietly forgives things a headless run cannot.

Wait for the layer to be ready. Tile and WMS providers fetch asynchronously, and a layout exported the microsecond after the layer is added can render before any tile has arrived. The reliable fix in a script is to render synchronously through QgsMapRendererParallelJob and wait for it, or to export the layout through the layout exporter, which blocks until rendering completes — the approach used in Automated Map Layout Generation.

Pin the resolution. An XYZ base map picks a zoom level from the current map scale, and a layout at 300 dpi asks for far more tiles than the screen does. Check the tile count implied by your export before scheduling it nightly against somebody else's free service; a single A0 poster can be tens of thousands of requests.

Respect the terms. Public tile services usually publish a usage policy that a batch job can breach without anybody meaning to. Attribution belongs on the map, and heavy repeated use belongs on a service you host or pay for.

Fail loudly, once. In a scheduled run, a transient network error should be retried a small number of times and then reported — not retried forever, and not swallowed. The pattern in Handle Errors and Logging in Unattended Scripts applies directly: a bounded retry with a clear final message beats both extremes.

import time

def load_with_retry(uri, name, provider, attempts=3, delay=5):
    for attempt in range(1, attempts + 1):
        layer = QgsVectorLayer(uri, name, provider)
        if layer.isValid():
            return layer
        if attempt < attempts:
            time.sleep(delay * attempt)
    raise RuntimeError(f"{name} unavailable after {attempts} attempts")

Breakdown: Three attempts with a growing pause covers the overwhelmingly common case — a service briefly overloaded — without turning a permanent outage into an infinite loop. Building a fresh layer each attempt matters, because a failed provider does not retry its connection on its own. Raising at the end means the job's exit status reflects reality, which is what a scheduler and a monitoring system act on.

Key takeaways

  • Ask what the service returns. Images (WMS, XYZ) are context; features (WFS) and raster pixels are data you can analyse.
  • Build URIs with QgsDataSourceUri rather than by hand — nearly every "invalid layer" against a working service is an escaping mistake.
  • Filter on the server. restrictToRequestBBOX plus a subset string is the difference between hundreds of features and hundreds of thousands.
  • Cloud-optimized GeoTIFFs are the exception that makes remote rasters practical — range requests and overviews mean you download what you look at.
  • Treat the network as a dependency: set a timeout, check isValid(), report the provider's error, and keep credentials in the authentication database.
  • Cache to GeoPackage for repeated analysis, and record when the snapshot was taken.

Frequently Asked Questions

Why is my WMS layer valid but blank? Usually a coordinate system the server does not actually support, despite advertising it, or an extent outside the layer's coverage. Check the capabilities document for the CRS list, and zoom to the layer's declared extent before concluding it is empty.

Can I run Processing algorithms on a WMS layer? Only the raster ones that operate on rendered pixels, and the result is a picture of data rather than data. If you need values, find a WFS, a WCS or a downloadable raster for the same dataset.

How do I load a service that needs an API key? Put the key in a QGIS authentication configuration and reference it by id in the URI. Embedding it directly works until the project is shared, at which point the key is shared too.

Why does my WFS layer take minutes to load? It is fetching everything. Add restrictToRequestBBOX='1' to the URI and a subset string for the attributes you need, and confirm the service supports version 2.0.0 paging.

Is an XYZ base map reprojected to my project CRS? Yes, on the fly, and it costs quality — tiles are published in web mercator and resampling them into another projection blurs labels baked into the image. For print-quality output, prefer a WMS that can render in your CRS directly.

What happens to remote layers when the project is opened offline? They load as invalid layers with their URIs intact and reconnect when the network returns. For field work, cache the layers to a GeoPackage first — a project of remote layers is useless without a connection.