Load an ArcGIS REST Feature Service in PyQGIS
A great deal of public and organisational geodata is published through Esri's ArcGIS REST API rather than OGC standards: council open data portals, utility networks, environmental agencies, emergency management dashboards. QGIS reads these services natively — vector features from a FeatureServer or MapServer layer through the arcgisfeatureserver provider, and rendered map images through arcgismapserver — so a PyQGIS script can pull them into an analysis without any Esri software.
This recipe belongs to Web Services and Remote Data in PyQGIS. It finds the right layer URL, loads features and map images, handles authentication, respects the server's paging limits, and copies the data locally when the work needs more than a quick look.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- The REST URL of a service. Open data portals usually expose it under an "API" or "View service" link; it contains
/rest/services/and ends inFeatureServerorMapServer, optionally followed by a layer number. - For secured services, credentials or a token, stored in the QGIS authentication database rather than in scripts — see storing credentials with QgsAuthManager.
Inspect the service before loading it
Every ArcGIS REST endpoint describes itself as JSON when f=json is added to the URL. Reading that description first tells you the layer's geometry type, its spatial reference, how many records the server returns per request, and whether queries are allowed at all.
import json
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.core import QgsBlockingNetworkRequest
LAYER_URL = "https://gis.example.gov/arcgis/rest/services/Utilities/FeatureServer/0"
def rest_json(url, **params):
q = QUrl(url)
query = "&".join(f"{k}={v}" for k, v in {"f": "json", **params}.items())
q.setQuery(query)
request = QgsBlockingNetworkRequest()
if request.get(QNetworkRequest(q)) != QgsBlockingNetworkRequest.NoError:
raise RuntimeError(request.errorMessage())
return json.loads(bytes(request.reply().content()))
info = rest_json(LAYER_URL)
print(info["name"], info["geometryType"], info.get("maxRecordCount"))
print("spatial reference:", info["extent"]["spatialReference"])
count = rest_json(f"{LAYER_URL}/query", where="1=1", returnCountOnly="true")["count"]
print("features on the server:", count)
Breakdown: QgsBlockingNetworkRequest goes through QGIS's own network stack, so proxy settings, SSL configuration and custom certificate authorities configured in QGIS apply to this request exactly as they will to the provider — the pattern is covered in making HTTP requests with QgsNetworkAccessManager. maxRecordCount is the most features the server returns in one response; the provider pages through larger layers automatically, but a layer of 400,000 features at 1,000 per page is 400 requests, and knowing that before starting is worth one extra call. returnCountOnly asks the server to count without sending geometry, which is cheap even for very large layers.
Load features as a vector layer
The vector provider takes a URI of key='value' pairs, the same format as database layers, so QgsDataSourceUri builds it correctly.
from qgis.core import QgsDataSourceUri, QgsVectorLayer, QgsProject
uri = QgsDataSourceUri()
uri.setParam("url", LAYER_URL)
uri.setParam("crs", "EPSG:27700")
mains = QgsVectorLayer(uri.uri(False), "water mains (service)", "arcgisfeatureserver")
if not mains.isValid():
raise RuntimeError(mains.error().summary())
print(mains.featureCount(), "features;", mains.wkbType(), mains.crs().authid())
QgsProject.instance().addMapLayer(mains)
Breakdown: The url points at a single layer — note the trailing /0 — because each vector layer maps to one REST layer. The crs parameter asks the server to return coordinates already projected into that CRS, which avoids reprojecting every feature locally; leave it out to receive the service's native spatial reference. The provider fetches features for the visible extent as you pan and caches them for the session, so the first draw of a large layer is slow and subsequent draws are fast. Attribute types come from the service's field definitions, including coded-value domains, which appear as plain codes rather than labels.
Add a MapServer as a raster basemap
Map services render images on the server. They are the right choice for backdrops — aerial imagery, a cadastral basemap, a styled utility overview — where you want the publisher's cartography and do not need the features themselves.
from qgis.core import QgsRasterLayer
ms = QgsDataSourceUri()
ms.setParam("url", "https://gis.example.gov/arcgis/rest/services/Basemap/MapServer")
ms.setParam("format", "png32")
ms.setParam("crs", "EPSG:27700")
ms.setParam("layer", "")
basemap = QgsRasterLayer(ms.uri(False), "council basemap", "arcgismapserver")
print(basemap.isValid(), basemap.dataProvider().description())
QgsProject.instance().addMapLayer(basemap, False)
QgsProject.instance().layerTreeRoot().insertLayer(-1, basemap)
Breakdown: Leaving layer empty requests the whole map with the publisher's layer visibility; naming a layer id restricts the image to that sublayer. png32 keeps transparency, which matters when the basemap sits above another layer. Services that publish pre-rendered tile caches are served as tiles, which is much faster than dynamic images at every pan. Inserting the layer at the bottom of the layer tree keeps it underneath the vector data, as in adding an XYZ tile basemap.
Authenticate against secured services
Many services require an Esri token or an organisation login. QGIS handles both through authentication configurations, so the credential never appears in a script, a project file or a layer source.
from qgis.core import QgsApplication
manager = QgsApplication.authManager()
config_id = "ags0001"
if config_id not in manager.configIds():
raise RuntimeError(f"create auth config {config_id} in Settings → Options → Authentication")
secured = QgsDataSourceUri()
secured.setParam("url", "https://gis.example.gov/arcgis/rest/services/Secure/Assets/FeatureServer/2")
secured.setAuthConfigId(config_id)
assets = QgsVectorLayer(secured.uri(False), "assets (secured)", "arcgisfeatureserver")
print(assets.isValid(), assets.featureCount())
Breakdown: The authentication configuration — typically Basic for ArcGIS Server's built-in token exchange, OAuth2 for ArcGIS Online and Portal, or ESRI token for a pre-issued token — is created once per machine, interactively or by a setup script, and scripts refer to it only by its seven-character id. setAuthConfigId adds authcfg= to the URI, and the provider attaches the credential to every request, refreshing tokens as needed. For unattended jobs on servers, create the configuration during provisioning and protect the authentication database with a master password supplied from the environment.
Copy the data locally for analysis
Working directly against a remote layer is fine for display and small queries. For analysis — joins, overlays, repeated iteration — copy the features into a GeoPackage once. It is faster, it is reproducible, and it stops a long script failing half-way because the server rate-limited you.
import processing
from datetime import datetime, timezone
snapshot = processing.run("native:savefeatures", {
"INPUT": mains,
"OUTPUT": "/data/cache/water_mains.gpkg",
"LAYER_NAME": "water_mains",
})["OUTPUT"]
local = QgsVectorLayer(snapshot, "water mains", "ogr")
metadata = local.metadata()
metadata.setAbstract(f"Snapshot of {LAYER_URL} taken "
f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC; "
f"{local.featureCount()} features.")
local.setMetadata(metadata)
local.saveNamedMetadata(snapshot + "|layername=water_mains")
print(local.featureCount(), "of", count, "features copied")
Breakdown: native:savefeatures pages through the whole service once and writes everything to disk. Comparing the copied count with the server's returnCountOnly answer catches a truncated download — the classic symptom of a service whose paging is misconfigured. Recording the source URL and a UTC timestamp in the layer's metadata answers the question every reviewer eventually asks, "how old is this?", and is the same idea as reading and writing layer metadata. Refresh the snapshot on a schedule when the analysis needs current data.
QGIS version compatibility
The arcgisfeatureserver and arcgismapserver providers have been part of QGIS since 3.0. Support for services that return PBF-encoded features, OAuth2 against ArcGIS Online, and automatic paging with resultOffset improved through the 3.2x releases, so use 3.40 or later against modern services. QgsBlockingNetworkRequest.NoError becomes QgsBlockingNetworkRequest.ErrorCode.NoError on the QGIS 4 series. Esri may change the REST API independently of QGIS; test scripts against the services they use after major server upgrades.
Troubleshooting
- The layer is invalid. The URL points at the service rather than a numbered layer, or the server requires authentication.
- Only 1,000 or 2,000 features arrive. The server does not support pagination; request smaller extents or ask the publisher for a download.
- Coordinates land in the wrong place. The service's spatial reference is a custom WKID QGIS cannot resolve; pass an explicit
crs. - Field values are codes. Coded-value domains are not translated; join a lookup table or read the domain from the layer's JSON.
- Requests fail with SSL errors. The server uses an internal certificate authority; add it in QGIS's SSL settings.
Conclusion
Inspect the layer's JSON first to learn its geometry, spatial reference, record limit and feature count. Load single REST layers with arcgisfeatureserver, basemaps with arcgismapserver, and attach credentials through an authentication configuration. For anything more than display, copy the service into a GeoPackage, check the count, and record when the snapshot was taken.
Frequently Asked Questions
Can I edit features on an ArcGIS Feature Service from QGIS? Editing support depends on the QGIS version and service capabilities and is more limited than for WFS-T or PostGIS. Treat these services as read sources in scripts.
How do I list all layers in a service?
Request the service URL with f=json; the layers and tables arrays list ids, names and types.
Do non-spatial tables work? Yes. Tables in a FeatureServer load as geometryless layers through the same provider.
Is this the same as the Esri "ArcGIS REST" connection in the browser panel? Yes. Connections saved there use the same providers, and a layer added from the browser shows the exact source string in its properties.