Load a WFS Layer in PyQGIS
A WFS layer is the one remote source that behaves like real data: geometry, attributes, a feature iterator, and every Processing algorithm willing to accept it. That similarity is also the trap. A getFeatures() loop that costs milliseconds against a local GeoPackage can spend twenty minutes pulling a national dataset across the internet, and it will do it again next time because nothing was cached.
This recipe belongs to Web Services and Remote Data in PyQGIS. It covers building the URI, discovering feature types, restricting requests to the current view, filtering on the server, the differences between WFS versions, and when to stop streaming and take a local copy.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- The endpoint URL of a WFS service and, ideally, the feature type name from its capabilities document.
- Enough patience for the first request against an unfamiliar service, which is nearly always slow.
Discover the feature types
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: The provider name is WFS in capitals — unlike wms and gdal, and a genuine source of confusion because the wrong case simply returns no metadata rather than raising. tables() parses the capabilities document and returns the published feature types with their geometry types, which saves guessing whether a name is flood_zones, env:flood_zones or ms:floodZones. Doing this once and hard-coding the answer is fine for a stable service; doing it every run is better where a provider renames things between releases.
Load the layer with sensible restrictions
from qgis.core import QgsVectorLayer, QgsProject
base = ("https://data.example.org/geoserver/wfs"
"?service=WFS&version=2.0.0&request=GetFeature"
"&typename=env:flood_zones"
"&srsname=EPSG:27700")
uri = f"{base} restrictToRequestBBOX='1'"
layer = QgsVectorLayer(uri, "Flood zones", "WFS")
if not layer.isValid():
raise RuntimeError(f"WFS layer invalid: {layer.error().summary()}")
layer.setSubsetString("risk_band = 'high'")
QgsProject.instance().addMapLayer(layer)
Breakdown: The URI is the service URL with its own query string, followed by space-separated QGIS parameters — that space before restrictToRequestBBOX is significant and is a common transcription error. restrictToRequestBBOX='1' makes the provider send the canvas extent with each request, so the service returns the current view instead of the whole country; leave it off and the first render downloads everything. srsname asks the server to deliver geometry in your project's coordinate system, avoiding a client-side reprojection of every feature. The subset string becomes a server-side filter, so the excluded rows never travel.
Adding a filter at load time rather than afterwards avoids one full request:
uri = (f"{base}&CQL_FILTER=risk_band%3D%27high%27 "
"restrictToRequestBBOX='1'")
Breakdown: CQL_FILTER is a GeoServer extension and the most readable way to filter at the source; percent-encoding is required because the value travels inside a query string. Services that do not support CQL accept an OGC FILTER document in XML instead, which QGIS also generates from a subset string. Where a service supports neither, the filtering happens in QGIS and the network cost stays.
Understand what the provider does per request
A WFS layer is lazier than it looks, and knowing when requests happen explains most surprises.
from qgis.core import QgsFeatureRequest
print(layer.featureCount()) # may be -1 until the provider knows
print(layer.extent()) # from capabilities, not from the features
request = QgsFeatureRequest().setFilterRect(view_extent)
for feature in layer.getFeatures(request):
... # this is when features actually arrive
Breakdown: featureCount() can return -1 on a service that does not advertise a count, which breaks any progress bar that assumes a total — check for it. The extent comes from the capabilities document, so it is the advertised coverage and may be wildly larger than the data. Feature iteration is where the network work happens: with restrictToRequestBBOX enabled, the rectangle in the request becomes the server's bounding box, which is why passing one matters far more here than against a local file. The general iteration advice in Speed Up Feature Iteration with QgsFeatureRequest applies, with the costs multiplied by network latency.
Cache it locally for analysis
Interactive browsing is what a live WFS layer is good at. Repeated analysis is not, because every run re-fetches data that has not changed.
from qgis.core import QgsVectorFileWriter, QgsCoordinateTransformContext
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "GPKG"
options.layerName = "flood_zones"
options.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer
error, message, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
layer, "/data/cache/remote.gpkg", QgsCoordinateTransformContext(), options)
if error != QgsVectorFileWriter.NoError:
raise RuntimeError(message)
Breakdown: This forces one full read of whatever the layer's current filters allow, and writes it to a local GeoPackage every later step reads instantly. Note the interaction with the subset string and bounding-box restriction: what gets written is what the layer currently exposes, so clear the restrictions first if you want the whole dataset cached, and keep them if you want a regional extract. Recording the fetch date alongside the cache — as layer metadata or a project variable — is what prevents somebody analysing a stale snapshot without knowing it, as described in Use Project Variables and Metadata in PyQGIS.
Page through a large feature type deliberately
When you genuinely need the whole dataset — a one-off migration, or building that local cache — take it in pages rather than in one request the server may refuse or truncate.
def fetch_pages(base_url, typename, page_size=5000, max_pages=200):
collected = []
for page in range(max_pages):
uri = (f"{base_url}?service=WFS&version=2.0.0&request=GetFeature"
f"&typename={typename}&srsname=EPSG:27700"
f"&count={page_size}&startIndex={page * page_size}")
layer = QgsVectorLayer(uri, f"page{page}", "WFS")
if not layer.isValid():
raise RuntimeError(f"page {page} failed to load")
features = list(layer.getFeatures())
collected.extend(features)
if len(features) < page_size:
break
return collected
Breakdown: count and startIndex are the WFS 2.0.0 paging parameters — on 1.1.0 the equivalent is maxFeatures with no reliable offset, which is one more reason to pin 2.0.0. A short page is how you know you have reached the end, so the loop exits on it rather than on a count you were told in advance and may not be able to trust. The max_pages ceiling is a safety net: without it, a service that ignores startIndex returns the same first page forever and the loop never ends. Accumulating features in memory is fine for a few hundred thousand small geometries and unwise beyond that — write each page into a GeoPackage instead and let the file grow rather than the process.
Two courtesies matter when doing this against somebody else's service: run it once and cache, rather than nightly; and check whether the same data is published as a bulk download, which is cheaper for both sides than paging through a hundred requests.
QGIS version compatibility
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | WFS 1.0/1.1/2.0, restrictToRequestBBOX, subset strings as described. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page; provider connection API for discovery. |
| 3.40 / 3.44 | 3.12 | Adds better OGC API — Features support through the OAPIF provider, which is worth preferring where a service offers both. |
Whichever release you are on, pin the WFS version in the URL rather than relying on negotiation. A service that answers 2.0.0 correctly and 1.1.0 badly will otherwise fail differently on different machines.
Troubleshooting
- The layer takes minutes and then works. No bounding-box restriction. Add
restrictToRequestBBOX='1'and a subset string. - Coordinates land in the wrong place entirely. Axis-order confusion between versions. Pin
version=2.0.0and setsrsnameexplicitly. featureCount()returns -1. The service does not advertise a count. Do not build a progress bar on it; count after caching locally instead.- Only the first few thousand features appear. The server enforces a maximum, or paging is not being used. Check for a
count/maxFeaturescap in the capabilities document. - The subset string is ignored. The service does not support the filter language QGIS generated. Test the equivalent request in a browser, and fall back to
CQL_FILTERor a client-side filter. - It works in QGIS but not in a scheduled job. The job's user has no proxy configuration or authentication database. Both are application-scope settings, not project settings.
Conclusion
A WFS layer gives you real features over HTTP, which makes it both genuinely useful and easy to misuse. Discover feature types through the provider metadata, pin the version and the CRS in the URI, always restrict to the request bounding box, push attribute filters to the server, and take a local GeoPackage copy the moment the same data is going to be analysed more than once.
Frequently Asked Questions
Why is the provider name WFS in capitals?
It simply is — the provider key is case-sensitive and differs from wms and gdal. A lower-case wfs produces an invalid layer with no explanation.
Can I edit a WFS layer? Only against a transactional WFS-T service, and support is uneven. For anything more than an occasional correction, edit in the source database instead.
How do I limit the number of features while testing?
Add maxFeatures (1.1.0) or count (2.0.0) to the URL. It is the fastest way to check a URI without waiting for a full download.
Does setSubsetString() always run on the server?
Only when the service supports the filter QGIS generates. When it does not, QGIS filters after fetching — same result, none of the saving.
Should I use OGC API — Features instead?
Where the service offers it, yes: JSON responses, standard HTTP caching and simpler paging make it better behaved over the modern web. Load it through the OAPIF provider.