Load an OGC API Features Layer in PyQGIS
OGC API – Features is the successor to WFS: the same idea of serving vector features over HTTP, rebuilt around plain REST resources and GeoJSON instead of XML capabilities documents and GML. National mapping agencies, statistics offices and open data platforms increasingly publish through it, and servers such as pygeoapi, ldproxy, GeoServer and QGIS Server all speak it. QGIS reads it through the OAPIF provider, and every resource is also ordinary JSON you can fetch from Python — which makes discovery and automation straightforward.
This recipe belongs to Web Services and Remote Data in PyQGIS. It walks a server's landing page to find collections, loads a collection as a layer, keeps requests small with extent restriction and paging, handles CRS choices, filters on the server where supported, and copies the result locally.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- The landing page URL of a server, such as
https://api.example.org/ogcapi. A browser opening it with?f=jsonshould show links toconformanceandcollections. - For secured servers, an authentication configuration, as for loading a WFS layer.
Discover collections from the landing page
Unlike WFS, there is no single capabilities document to parse. The collections resource lists what the server offers, with enough metadata to choose one — and the conformance resource says which optional capabilities, such as other CRSs or filtering, the server supports.
import json
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.core import QgsBlockingNetworkRequest
ROOT = "https://api.example.org/ogcapi"
def get_json(path, **params):
url = QUrl(f"{ROOT}{path}")
url.setQuery("&".join(f"{k}={v}" for k, v in {"f": "json", **params}.items()))
req = QNetworkRequest(url)
req.setRawHeader(b"Accept", b"application/json")
blocking = QgsBlockingNetworkRequest()
if blocking.get(req) != QgsBlockingNetworkRequest.NoError:
raise RuntimeError(blocking.errorMessage())
return json.loads(bytes(blocking.reply().content()))
conforms = get_json("/conformance")["conformsTo"]
supports_crs = any("ogcapi-features-2" in c and "crs" in c for c in conforms)
supports_filter = any("filter" in c for c in conforms)
print("CRS extension:", supports_crs, "| filtering:", supports_filter)
for col in get_json("/collections")["collections"]:
bbox = col.get("extent", {}).get("spatial", {}).get("bbox", [[None]])[0]
print(f"{col['id']:<28} {col.get('title', ''):<40} crs={len(col.get('crs', []))} bbox={bbox}")
Breakdown: The conformance list is a set of URIs naming the parts of the standard the server implements; Part 2 adds coordinate reference systems beyond WGS 84 longitude/latitude, and Part 3 adds filtering. Checking them before building layers avoids asking a server for something it will silently ignore. The collection id is what the provider needs; the title is for humans. The spatial extent bbox is always expressed in CRS84 longitude, latitude order, whatever CRS the data is stored in.
Load a collection as a layer
The provider takes the landing page URL and a collection id as typename. Building the URI with QgsDataSourceUri keeps the quoting right.
from qgis.core import QgsDataSourceUri, QgsVectorLayer, QgsProject
uri = QgsDataSourceUri()
uri.setParam("url", ROOT)
uri.setParam("typename", "buildings")
uri.setParam("restrictToRequestBBOX", "1")
buildings = QgsVectorLayer(uri.uri(False), "buildings (OGC API)", "OAPIF")
if not buildings.isValid():
raise RuntimeError(buildings.error().summary())
print(buildings.crs().authid(), buildings.wkbType(), buildings.fields().names())
QgsProject.instance().addMapLayer(buildings)
Breakdown: restrictToRequestBBOX=1 makes the provider request only features in the current map view, re-requesting as you pan, which is the only sensible mode for a national buildings collection with millions of items. Without it, the provider pages through the entire collection on first access. Field names and types are inferred from the collection's schema when the server publishes one, or from the first page of features otherwise, so a property that is null in every early feature may arrive typed as a string.
Choose the CRS
By default every OGC API Features server returns WGS 84 longitude/latitude (CRS84). Servers that implement Part 2 can return other CRSs listed in the collection metadata, which saves reprojecting every feature locally and preserves the precision of projected source data.
collection = get_json("/collections/buildings")
offered = collection.get("crs", [])
print("offered:", offered[:5], "…" if len(offered) > 5 else "")
wanted = "http://www.opengis.net/def/crs/EPSG/0/25833"
uri = QgsDataSourceUri()
uri.setParam("url", ROOT)
uri.setParam("typename", "buildings")
uri.setParam("restrictToRequestBBOX", "1")
if wanted in offered:
uri.setParam("srsname", "EPSG:25833")
buildings_utm = QgsVectorLayer(uri.uri(False), "buildings (UTM 33N)", "OAPIF")
print(buildings_utm.crs().authid())
Breakdown: CRSs are identified by OGC URIs in the collection metadata; the provider accepts the familiar EPSG: form. Only request a CRS the collection lists — an unsupported request typically falls back to CRS84 without an error, which is harmless for display and confusing for anyone expecting metres. If features appear mirrored across the equator or in the ocean, the server is emitting latitude-first coordinates while claiming CRS84; that is a server bug, and a quick check against a known location before any analysis is worth the thirty seconds.
Filter on the server and page sensibly
Pulling a whole collection to throw most of it away wastes the server's time and yours. Where the server supports filtering, a subset string can be pushed to it; where it does not, restrict by extent and filter locally.
from qgis.core import QgsRectangle, QgsFeatureRequest
buildings.setSubsetString("\"building_use\" = 'school'")
print("valid after filter:", buildings.isValid())
area = QgsRectangle(13.35, 52.49, 13.45, 52.55)
request = QgsFeatureRequest().setFilterRect(area).setLimit(5000)
schools = list(buildings.getFeatures(request))
print(len(schools), "school buildings in the area")
Breakdown: On recent QGIS releases, when the server advertises filtering support, the provider translates simple subset expressions into a server-side filter so only matching items are transferred; on servers without it the subset is applied locally after download. Either way the result is correct, and only the amount of data transferred differs — so combining a subset with an extent filter is always safe. setFilterRect becomes a bbox parameter on the items request. setLimit protects a script from an unexpectedly huge result, which is good practice against any remote source.
Take a local snapshot
For analysis, copy the filtered features to a GeoPackage and record where and when they came from — the same discipline as for any web service.
import processing
from datetime import datetime, timezone
buildings.setSubsetString("\"building_use\" = 'school'")
out = processing.run("native:savefeatures", {
"INPUT": buildings,
"OUTPUT": "/data/cache/schools_ogcapi.gpkg",
"LAYER_NAME": "schools",
})["OUTPUT"]
local = QgsVectorLayer(out, "schools", "ogr")
md = local.metadata()
md.setAbstract(f"OGC API Features collection 'buildings' at {ROOT}, "
f"filter building_use = 'school', retrieved "
f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC")
local.setMetadata(md)
local.saveNamedMetadata(out + "|layername=schools")
print(local.featureCount(), "features saved")
Breakdown: Saving from the filtered layer transfers only the matching items. Because restrictToRequestBBOX only affects map rendering requests, a full save still pages through every matching item in the collection — which is what you want for a snapshot, and why the filter matters. The abstract records the server, the collection, the filter and a UTC timestamp, so a reader of the GeoPackage months later can tell exactly what it represents and re-run the query; the same habit is shown for ArcGIS REST feature services. Many OGC API servers also publish a timeStamp on each items response, which is worth copying into the metadata when present, because it describes the data rather than the download.
QGIS version compatibility
The OAPIF provider has been available since QGIS 3.14 (under its original "WFS 3" name before that). Support for CRSs other than CRS84, server-side filtering and editing through Part 4 (transactions) arrived progressively in the 3.2x releases; use 3.40 or newer against current servers. QgsBlockingNetworkRequest.NoError needs its scoped form, QgsBlockingNetworkRequest.ErrorCode.NoError, on the QGIS 4 series.
Troubleshooting
- The layer is invalid.
urlpoints at the collection or items resource instead of the landing page, ortypenameis the title instead of the id. - Features are in the ocean. The server writes latitude first while claiming CRS84; report it and swap axes locally meanwhile.
- The first draw takes minutes.
restrictToRequestBBOXis off, so the whole collection is being fetched. - Filters have no effect on download size. The server does not support filtering; the subset is applied locally.
- Some attributes are strings instead of numbers. Types were inferred from early features where the value was null.
Conclusion
Start from the landing page: read conformance to learn what the server supports and the collections list to find ids. Load collections with the OAPIF provider and restrictToRequestBBOX, request a projected CRS only when the collection offers it, filter on the server where possible, and snapshot to GeoPackage for analysis.
Frequently Asked Questions
Is OGC API Features replacing WFS? For new publications, largely yes, though WFS remains widely deployed. QGIS reads both, and scripts that snapshot to GeoPackage do not care which one fed them.
Can I edit features through OGC API Features? Where the server implements Part 4 and QGIS supports it, yes. Most public servers are read-only.
Does QGIS Server publish OGC API Features? Yes. Projects published with QGIS Server expose their vector layers as collections, as covered in publishing layers with QGIS Server.
Why fetch JSON myself if the provider does it? Discovery. The provider needs a collection id; the landing page and collections list are how a script finds one without a human browsing the server.