QGIS Data Providers Explained: How QGIS Reads Any Data Source
On this page
Problem statement
Three lines that look almost identical and behave completely differently:
QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "ogr")
QgsVectorLayer("Point?crs=EPSG:27700&field=id:integer", "scratch", "memory")
QgsVectorLayer("dbname='gis' host=localhost table=\"public\".\"rutland_parcels\" (geom)", "p", "postgres")
The third argument is the provider key, and it decides how the first argument is interpreted. Get it wrong and you get an invalid layer with no exception — and, as the runs below show, usually with no error message either. It is the single most common PyQGIS confusion.
Providers are also the reason some layers can be edited and others cannot, why setSubsetString sometimes runs on a server, and why a layer's feature count can disagree with the features you iterate. Every result on this page comes from running the code under QGIS 4.2 on Rutland's 21,253 Land Registry parcels — as a GeoPackage and as a PostGIS table — its OpenStreetMap points of interest as a CSV, and a live WFS.
Quick answer
A provider is the plugin that connects a layer to a data source:
- The provider key —
ogr,gdal,postgres,memory,delimitedtext,WFS,wms— selects the implementation - The URI is provider-specific: a path for
ogr, afile://URL fordelimitedtext, a key-value string forpostgres - Providers declare capabilities: can they add features, change attributes, do they support transactions?
- Providers cache metadata such as the feature count;
reload()refreshes it QgsVectorLayernever raises — always checkisValid(), and when it isFalseread the QGIS message log, because the provider's own error is usually empty
from qgis.core import QgsProviderRegistry, QgsVectorLayer
providers = QgsProviderRegistry.instance().providerList()
print(len(providers), "providers:", providers)
layer = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "ogr")
print(layer.isValid(), layer.providerType(), layer.dataProvider().name(), layer.featureCount())
34 providers: ['OAPIF', 'WFS', 'arcgisfeatureserver', 'arcgisimageserver', 'arcgismapserver', 'arcgisvectortileservice', 'cesiumtiles', 'copc', 'delimitedtext', 'ept', 'esrii3s', 'gdal', 'gpx', 'hana', 'mbtilesvectortiles', 'mdal', 'memory', 'mesh_memory', 'mssql', 'ogr', 'postgres', 'postgresraster', 'quantizedmesh', 'sensorthings', 'spatialite', 'tiledscene', 'vectortile', 'virtual', 'virtualraster', 'vpc', 'vtpkvectortiles', 'wcs', 'wms', 'xyzvectortiles']
True ogr ogr 21253
One layer class, many providers
Provider keys, capabilities, metadata and filters
The provider key decides everything
csv_path = Path("data/pois.csv").resolve()
wfs = ("pagingEnabled='true' url='https://data.3dbag.nl/api/BAG3D/wfs' "
"typename='BAG3D:lod22' maxNumFeatures='5'")
attempts = {
# ogr - the general file provider, backed by GDAL's vector half
"ogr, GeoPackage layer": ("data/parcels.gpkg|layername=parcels", "ogr"),
"ogr, shapefile in a zip": ("/vsizip/data/rutland-osm.zip/gis_osm_roads_free_1.shp", "ogr"),
# delimitedtext - a CSV with coordinates, as a file:// URL with query parameters
"delimitedtext, file:// URL": (f"{csv_path.as_uri()}?delimiter=,&xField=lon&yField=lat&crs=EPSG:4326",
"delimitedtext"),
# memory - a scratch layer defined by its geometry type and fields
"memory": ("Point?crs=EPSG:27700&field=id:integer&field=name:string(80)", "memory"),
# postgres - a connection string, built with QgsDataSourceUri
"postgres": (pg_uri.uri(False), "postgres"),
# WFS - a remote service (the 3D BAG, Netherlands)
"WFS": (wfs, "WFS"),
# the same sources with the wrong grammar or key
"delimitedtext, bare path": (str(csv_path), "delimitedtext"),
"ogr, delimitedtext's URL": (f"{csv_path.as_uri()}?delimiter=,&xField=lon&yField=lat", "ogr"),
"postgres, a GeoPackage path": ("data/parcels.gpkg", "postgres"),
"wfs, lower case": (wfs, "wfs"),
}
for label, (uri, key) in attempts.items():
layer = QgsVectorLayer(uri, label, key)
dp = layer.dataProvider()
detail = f"{layer.featureCount():>6} features" if layer.isValid() else f"error message: {dp.error().message()!r}"
print(f"{label:28} valid={str(layer.isValid()):5} {detail}")
ogr, GeoPackage layer valid=True 21253 features
ogr, shapefile in a zip valid=True 7292 features
delimitedtext, file:// URL valid=True 531 features
memory valid=True 0 features
postgres valid=True 21253 features
WFS valid=True 30111228 features
delimitedtext, bare path valid=False error message: ''
ogr, delimitedtext's URL valid=False error message: ''
postgres, a GeoPackage path valid=False error message: ''
wfs, lower case valid=True 30111228 features
Passing a bare path with the delimitedtext key, or delimitedtext's URL with ogr, gives an invalid layer whose provider reports nothing at all — the URI grammar belongs to the provider. Provider keys, on the other hand, were not case-sensitive for the WFS: WFS and wfs both worked. The WFS count is the server's own figure for the whole 3D BAG layer; maxNumFeatures='5' did not change it.
Capabilities: what a provider can actually do
V = QgsVectorDataProvider
FLAGS = {"add": V.AddFeatures, "delete": V.DeleteFeatures, "change attrs": V.ChangeAttributeValues,
"change geom": V.ChangeGeometries, "add fields": V.AddAttributes,
"transactions": V.TransactionSupport, "fast truncate": V.FastTruncate}
def capabilities(layer) -> list[str]:
caps = layer.dataProvider().capabilities()
return [name for name, flag in FLAGS.items() if caps & flag]
sources = {
"ogr, GeoPackage": QgsVectorLayer("data/parcels_copy.gpkg|layername=parcels", "gpkg", "ogr"),
"ogr, CSV": QgsVectorLayer(str(csv_path), "csv", "ogr"),
"delimitedtext, CSV": QgsVectorLayer(attempts["delimitedtext, file:// URL"][0], "dt", "delimitedtext"),
"memory": QgsVectorLayer("Point?crs=EPSG:27700&field=id:integer", "mem", "memory"),
"postgres": QgsVectorLayer(pg_uri.uri(False), "pg", "postgres"),
"WFS": QgsVectorLayer(wfs, "wfs", "WFS"),
}
for label, layer in sources.items():
print(f" {label:20} {', '.join(capabilities(layer)) or 'read-only'}")
ogr, GeoPackage add, delete, change attrs, change geom, add fields, transactions
ogr, CSV add, delete, change attrs, change geom, add fields
delimitedtext, CSV read-only
memory add, delete, change attrs, change geom, add fields, fast truncate
postgres add, delete, change attrs, change geom, add fields, transactions, fast truncate
WFS add, delete, change attrs, change geom
This is how a plugin decides whether to offer an "edit" button, and how your script should decide whether an edit is even possible. Note that it is a property of the provider, not the file: the same CSV is editable through ogr — GDAL's CSV driver can write — and read-only through delimitedtext. And a capability is a claim, not a guarantee: the WFS advertised edits because the service's description allows them, and whether a transaction would actually be accepted is up to the server.
Cached metadata: count versus contents
pg = QgsVectorLayer(pg_uri.uri(False), "parcels", "postgres")
gpkg = QgsVectorLayer("data/parcels_copy.gpkg|layername=parcels", "parcels", "ogr")
print(f"before: postgres {pg.featureCount():,} gpkg {gpkg.featureCount():,}")
# someone else adds three parcels to each, outside this QGIS session
psql("INSERT INTO rutland_parcels (inspireid, geom) SELECT -1, geom FROM rutland_parcels LIMIT 3")
gdal.VectorTranslate("data/parcels_copy.gpkg", "data/parcels_copy.gpkg", accessMode="append",
layerName="parcels", SQLStatement="SELECT * FROM parcels LIMIT 3")
print(f"featureCount(): postgres {pg.featureCount():,} gpkg {gpkg.featureCount():,}")
print(f"iterated: postgres {sum(1 for _ in pg.getFeatures()):,} gpkg {sum(1 for _ in gpkg.getFeatures()):,}")
pg.reload()
gpkg.reload()
print(f"after reload(): postgres {pg.featureCount():,} gpkg {gpkg.featureCount():,}")
psql("DELETE FROM rutland_parcels WHERE inspireid = -1")
before: postgres 21,253 gpkg 21,253
featureCount(): postgres 21,253 gpkg 21,253
iterated: postgres 21,256 gpkg 21,256
after reload(): postgres 21,256 gpkg 21,256
Both providers fetched the new rows as soon as they were iterated, and both kept reporting the old count until reload(). The difference between a database and a file is not that one is live and the other a snapshot — features are read from the source on demand in both — it is what else the source offers: concurrent writers, transactions, and filters evaluated where the data lives.
Filters: pushed down or evaluated locally
with postgres_statements() as statements:
pg.setSubsetString("ST_Area(geom) > 100000")
print(f"postgres, parcels over 10 ha: {pg.featureCount():,} of 21,253")
n = sum(1 for _ in pg.getFeatures())
for sql in statements:
print(" server ran:", sql if len(sql) <= 100 else sql[:28] + " ..." + sql[sql.rindex(" FROM "):])
pg.setSubsetString("")
for subset in ("ST_Area(geom) > 100000", "ST_Area(geometry) > 100000"):
accepted = gpkg.setSubsetString(subset)
print(f"gpkg, {subset!r}: accepted={accepted}, {gpkg.featureCount():,} features, "
f"extent {gpkg.extent().toString(0)}")
gpkg.setSubsetString("")
print(f"gpkg, cleared: {gpkg.featureCount():,} features, extent {gpkg.extent().toString(0)}")
postgres, parcels over 10 ha: 926 of 21,253
server ran: SELECT * FROM "public"."rutland_parcels" WHERE ST_Area(geom) > 100000 LIMIT 0
server ran: SELECT count(*) FROM "public"."rutland_parcels" WHERE (ST_Area(geom) > 100000)
server ran: BEGIN READ ONLY;DECLARE qgis ... FROM "public"."rutland_parcels" WHERE ((ST_Area(geom) > 100000))
gpkg, 'ST_Area(geom) > 100000': accepted=True, -1 features, extent Null
gpkg, 'ST_Area(geometry) > 100000': accepted=True, 927 features, extent 479001,292381 : 506878,319201
gpkg, cleared: 21,256 features, extent 479001,292381 : 506878,319201
For the postgres provider the subset string is SQL, and the server log shows it arriving as a WHERE clause in the count and in the cursor that fetches features — the rows never leave the server. Because it is passed through verbatim, it can use any PostGIS function.
The same string on the GeoPackage went to GDAL's SQLite dialect instead, and failed in the least helpful way: setSubsetString() returned True, and the feature count came back as -1, because the GeoPackage's geometry column is called geometry, not geom. GDAL printed the SQL error to the console and nothing reached Python. Naming the right column found 927 parcels over 10 ha — one more than PostGIS, because one of the three rows appended above copied a 32 ha parcel. For a delimitedtext CSV the provider evaluates the filter itself. Same API, three dialects, very different cost. A subset also changes the layer persistently: the count and the extent are those of the subset until it is cleared.
The provider connection API
QGIS exposes one API for browsing and managing connections, whatever the provider:
metadata = QgsProviderRegistry.instance().providerMetadata("postgres")
connection = metadata.createConnection(pg_uri.uri(False), {})
print("schemas:", connection.schemas())
for table in connection.tables("public"):
if table.tableName().startswith("rutland"):
print(f" {table.tableName():18} {[QgsWkbTypes.displayString(t.wkbType) for t in table.geometryColumnTypes()]}")
print(connection.executeSql("SELECT count(*), round(sum(ST_Area(geom)) / 1e6) FROM rutland_parcels"))
schemas: ['ogr_system_tables', 'public', 'results', 'tiger', 'tiger_data', 'topology']
rutland_parcels ['Polygon']
rutland_wards ['Polygon']
[[21253, 429.0]]
The same shape works for a GeoPackage through the ogr provider — with one exception, shown in example 4: a file has no schemas, and asking for them raises rather than returning an empty list. It is what the QGIS Browser panel uses, and it is far cleaner than assembling driver-specific calls.
Diagnosing an invalid layer
def diagnose(uri: str, name: str, provider: str):
if provider not in QgsProviderRegistry.instance().providerList():
return f"provider {provider!r} is not registered - is QGIS initialised?"
with captured_messages() as log:
layer = QgsVectorLayer(uri, name, provider)
if layer.isValid():
return f"ok: {layer.featureCount()} features, {layer.crs().authid()}"
reason = layer.dataProvider().error().message() if layer.dataProvider() else ""
return f"invalid: {reason or (log[-1] if log else 'no message from the provider or the log')}"
print(diagnose("data/parcels.gpkg|layername=parcels", "parcels", "ogr"))
print(diagnose("data/parcels.gpkg|layername=parcel", "typo", "ogr"))
print(diagnose("data/parcels.gpkg", "wrong-key", "postgres"))
print(diagnose("data/parcels.gpkg", "no-such-key", "postgis"))
ok: 21253 features, EPSG:27700
invalid: [OGR] Cannot find layer parcel.()
invalid: no message from the provider or the log
provider 'postgis' is not registered - is QGIS initialised?
Four outcomes. An unknown key is caught before a layer is made. A provider that tried and failed left its reason in the message log, not in error(). And a wrong key — a GeoPackage path handed to postgres — produced nothing anywhere: the provider parsed the path as an empty connection string and gave up quietly. That is why so many people see no message.
Sublayers: one source, many layers
for sub in QgsProviderRegistry.instance().querySublayers("data/atlas.gpkg"):
print(f"{sub.name():10} {QgsWkbTypes.displayString(sub.wkbType()):14} {sub.providerKey():5} {Path(sub.uri()).name}")
parcels Polygon ogr atlas.gpkg|layername=parcels
schools Polygon ogr atlas.gpkg|layername=schools
landuse Polygon ogr atlas.gpkg|layername=landuse
querySublayers is the provider-agnostic way to ask "what is in this file?" — it works for GeoPackage, File Geodatabase, NetCDF, and anything else that can hold several datasets. Using it before constructing a layer removes the guesswork about |layername=, and it returns the URI to use.
Rutland's parcels through GeoPackage, PostGIS and memory
The data used on this page
HM Land Registry's INSPIRE Index Polygons for Rutland (© Crown copyright and database rights, HM Land Registry; OGL), loaded into a GeoPackage and into PostGIS 3.4 in a local container; Geofabrik's OpenStreetMap extract for Rutland of 1 January 2026 (© OpenStreetMap contributors, ODbL), whose points of interest become a CSV and whose schools and land use join the parcels in a three-layer GeoPackage; and the 3D BAG WFS for a remote source.
import os
import subprocess
import time
from contextlib import contextmanager
from pathlib import Path
import requests
from osgeo import gdal, ogr
gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
os.environ.update({"PGHOST": "localhost", "PGPORT": "55433", "PGDATABASE": "gis",
"PGUSER": "user", "PGPASSWORD": "guide"}) # a throwaway container's credentials
session = requests.Session()
for name, url in {
"Rutland.zip": "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip",
"rutland-osm.zip": "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
}.items():
if not Path("data", name).exists():
Path("data", name).write_bytes(session.get(url, timeout=300).content)
if not Path("data/atlas.gpkg").exists():
gdal.VectorTranslate("data/parcels.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
layerName="parcels", selectFields=["INSPIREID"])
osm = "/vsizip/data/rutland-osm.zip/"
gdal.VectorTranslate("data/atlas.gpkg", "data/parcels.gpkg", layerName="parcels")
gdal.VectorTranslate("data/atlas.gpkg", osm + "gis_osm_pois_a_free_1.shp", accessMode="append", layerName="schools",
where="fclass = 'school'", dstSRS="EPSG:27700", reproject=True)
gdal.VectorTranslate("data/atlas.gpkg", osm + "gis_osm_landuse_a_free_1.shp", accessMode="append",
layerName="landuse", dstSRS="EPSG:27700", reproject=True)
gdal.VectorTranslate("data/pois.csv", osm + "gis_osm_pois_free_1.shp", format="CSV",
SQLStatement="SELECT name, fclass, ST_X(geometry) AS lon, ST_Y(geometry) AS lat "
"FROM gis_osm_pois_free_1", SQLDialect="SQLite")
gdal.VectorTranslate("data/parcels_copy.gpkg", "data/parcels.gpkg") # a copy the page may change
if os.system("docker inspect guide-postgis >/dev/null 2>&1") != 0:
os.system("docker run -d --name guide-postgis -e POSTGRES_USER=user -e POSTGRES_PASSWORD=guide "
"-e POSTGRES_DB=gis -p 127.0.0.1:55433:5432 postgis/postgis:16-3.4 >/dev/null")
time.sleep(10)
gdal.VectorTranslate("PG:host=localhost port=55433 dbname=gis user=user password=guide", "data/parcels.gpkg",
layerName="rutland_parcels", accessMode="overwrite",
layerCreationOptions=["GEOMETRY_NAME=geom", "FID=gid"])
def psql(sql):
return subprocess.run(["psql", "-qtAc", sql], capture_output=True, text=True, check=True).stdout.strip()
@contextmanager
def postgres_statements():
"""Switch statement logging on in the container and collect what this block sends."""
psql("ALTER SYSTEM SET log_statement = 'all'"); psql("SELECT pg_reload_conf()")
since = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 1))
statements = []
try:
yield statements
finally:
psql("ALTER SYSTEM RESET log_statement"); psql("SELECT pg_reload_conf()")
time.sleep(1) # the server's log reaches `docker logs` a moment later
logs = subprocess.run(["docker", "logs", "--since", since, "guide-postgis"], capture_output=True, text=True)
statements += [l.split("statement: ", 1)[1] for l in logs.stderr.splitlines()
if "statement: " in l and "rutland_parcels" in l]
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (Qgis, QgsApplication, QgsDataSourceUri, QgsFeature, QgsGeometry, QgsPointXY,
QgsProject, QgsProviderRegistry, QgsVectorDataProvider, QgsVectorLayer, QgsWkbTypes)
qgs = QgsApplication([], False)
qgs.initQgis()
@contextmanager
def captured_messages():
messages = []
def on_message(message, tag, level, text_format=None):
messages.append(f"[{tag}] {message}")
QgsApplication.messageLog().messageReceivedWithFormat.connect(on_message)
try:
yield messages
finally:
QgsApplication.processEvents()
QgsApplication.messageLog().messageReceivedWithFormat.disconnect(on_message)
pg_uri = QgsDataSourceUri()
pg_uri.setConnection("localhost", "55433", "gis", "", "") # user and password come from PGUSER/PGPASSWORD
pg_uri.setDataSource("public", "rutland_parcels", "geom", "", "gid")
print("QGIS", Qgis.QGIS_VERSION, "|", psql("SELECT count(*) FROM rutland_parcels"), "parcels in PostGIS |",
sum(1 for _ in open("data/pois.csv")) - 1, "points of interest in the CSV")
QGIS 4.2.2-Belém do Pará | 21253 parcels in PostGIS | 531 points of interest in the CSV
Example 1: A loader that speaks every provider
from urllib.parse import quote
from qgis.core import QgsRasterLayer
def load(source, name=None, kind="auto", **options):
"""Build the right URI for the right provider, and fail with a real message."""
if kind == "auto":
text = str(source).lower()
if text.endswith((".tif", ".tiff", ".img", ".vrt")):
kind = "raster"
elif text.endswith(".csv"):
kind = "csv"
elif text.startswith(("postgres", "dbname")):
kind = "postgres"
else:
kind = "vector"
with captured_messages() as log:
if kind == "raster":
layer = QgsRasterLayer(str(Path(source).resolve()), name or Path(source).stem, "gdal")
elif kind == "csv":
path = Path(source).resolve()
uri = (f"file://{quote(str(path))}?delimiter={options.get('delimiter', ',')}"
f"&xField={options.get('x', 'lon')}&yField={options.get('y', 'lat')}"
f"&crs={options.get('crs', 'EPSG:4326')}&detectTypes=yes")
layer = QgsVectorLayer(uri, name or path.stem, "delimitedtext")
elif kind == "postgres":
layer = QgsVectorLayer(str(source), name or "postgres layer", "postgres")
else:
path = Path(str(source).split("|")[0]).resolve()
uri = f"{path}|layername={options['layer']}" if options.get("layer") else str(path)
layer = QgsVectorLayer(uri, name or path.stem, "ogr")
if not layer.isValid():
reason = (layer.dataProvider().error().message() if layer.dataProvider() else "") or "; ".join(log)
raise RuntimeError(f"could not load {source!r} as {kind}: {reason or 'no message'}")
return layer
for source, opts in (("data/atlas.gpkg", {"layer": "schools"}), ("data/pois.csv", {}),
(pg_uri.uri(False), {}), ("data/atlas.gpkg", {"layer": "school"})):
try:
layer = load(source, **opts)
print(f"{layer.providerType():14} {layer.name():14} {layer.featureCount():>6} features, {layer.crs().authid()}")
except RuntimeError as exc:
print("RuntimeError:", str(exc).replace(str(Path.cwd()) + "/", ""))
ogr atlas 26 features, EPSG:27700
delimitedtext pois 531 features, EPSG:4326
postgres postgres layer 21253 features, EPSG:27700
RuntimeError: could not load 'data/atlas.gpkg' as vector: [OGR] Cannot find layer school.()
Example 2: Check before you edit
def can_edit(layer) -> tuple[bool, str]:
provider = layer.dataProvider()
if provider is None:
return False, "no provider"
caps = provider.capabilities()
needed = {
"add": QgsVectorDataProvider.AddFeatures,
"change attributes": QgsVectorDataProvider.ChangeAttributeValues,
"change geometry": QgsVectorDataProvider.ChangeGeometries,
}
missing = [name for name, flag in needed.items() if not caps & flag]
if missing:
return False, f"{layer.providerType()} cannot: {', '.join(missing)}"
if layer.readOnly():
return False, "layer is marked read-only"
return True, "editable"
for label in ("ogr, GeoPackage", "delimitedtext, CSV", "postgres"):
print(f"{label:20}", *can_edit(sources[label]))
sources["ogr, GeoPackage"].setReadOnly(True)
print(f"{'ogr, GeoPackage':20}", *can_edit(sources["ogr, GeoPackage"]), "(after setReadOnly)")
ogr, GeoPackage True editable
delimitedtext, CSV False delimitedtext cannot: add, change attributes, change geometry
postgres True editable
ogr, GeoPackage False layer is marked read-only (after setReadOnly)
Example 3: Build a scratch layer in memory — and lose it
# the memory provider's URI *is* the schema
layer = QgsVectorLayer(
"Polygon?crs=EPSG:27700"
"&field=id:integer"
"&field=name:string(80)"
"&field=area_m2:double(12,2)"
"&index=yes",
"scratch", "memory")
print(layer.isValid(), [f.name() for f in layer.fields()])
oakham = next(QgsVectorLayer("data/atlas.gpkg|layername=schools", "s", "ogr").getFeatures("\"name\" = 'Oakham School'"))
feature = QgsFeature(layer.fields())
feature.setGeometry(oakham.geometry())
feature["id"], feature["name"], feature["area_m2"] = 1, oakham["name"], oakham.geometry().area()
layer.dataProvider().addFeatures([feature])
layer.updateExtents()
print(layer.featureCount(), "feature,", f"{feature['area_m2']:,.0f} m²")
project = QgsProject.instance()
project.addMapLayer(layer)
project.write("scratch.qgz")
project.clear()
project.read("scratch.qgz")
print("after saving and reopening the project:",
[(l.name(), l.providerType(), l.isValid(), l.featureCount()) for l in project.mapLayers().values()])
project.clear()
True ['id', 'name', 'area_m2']
1 feature, 285,796 m²
after saving and reopening the project: [('scratch', 'memory', True, 0)]
Memory layers are the natural staging area for constructed features, algorithm outputs and test fixtures — no file, no cleanup, full API. They are also exactly as durable as the process. The project saved the layer's definition and not its data: reopened, the scratch layer was valid and empty. Write anything worth keeping to a GeoPackage.
Example 4: Browse any source with the connection API
from qgis.core import QgsProviderConnectionException
def browse(provider_key: str, uri: str, prefix: str = ""):
metadata = QgsProviderRegistry.instance().providerMetadata(provider_key)
if metadata is None:
raise ValueError(f"no such provider: {provider_key}")
try:
connection = metadata.createConnection(uri, {})
except QgsProviderConnectionException as exc:
raise RuntimeError(f"cannot connect: {exc}") from exc
try:
schemas = connection.schemas()
except QgsProviderConnectionException as exc:
print(f" ({exc})")
schemas = [""] # file-based providers have no schemas
for schema in schemas:
for table in connection.tables(schema):
if table.tableName().startswith(prefix):
types = [QgsWkbTypes.displayString(t.wkbType) for t in table.geometryColumnTypes()]
print(f" {schema or '-':8} {table.tableName():18} {types or 'no geometry'}")
print("GeoPackage:")
browse("ogr", str(Path("data/atlas.gpkg").resolve()))
print("PostGIS:")
browse("postgres", pg_uri.uri(False), prefix="rutland")
GeoPackage:
(Operation 'Schemas' is not supported for this connection)
- parcels ['Polygon']
- schools ['Polygon']
- landuse ['Polygon']
PostGIS:
public rutland_parcels ['Polygon']
public rutland_wards ['Polygon']
One function, two very different sources — which is exactly the abstraction providers exist to give you.
The page's QGIS session ends here:
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed
What a layer is versus where its data lives
QGIS separates what a layer is from where its data comes from. QgsVectorLayer handles rendering, styling, selection, the edit buffer and the layer tree; a QgsVectorDataProvider handles fetching features, reporting the schema, and applying changes. Everything a layer can do that involves data goes through its provider.
That separation is what lets one application read a shapefile, a PostGIS table, a WFS service and a CSV with the same code paths for symbology, labelling and analysis. It also explains the differences you notice in use. A postgres layer turns a subset string into SQL, can participate in transactions, and shares its table with other writers; an ogr layer over a GeoPackage has one writer at a time; a WFS layer pages requests over HTTP. What they have in common matters as much: both the database and the file layer read features on demand and cache their counts until reload().
Capabilities are how a provider advertises those differences. Rather than the application knowing which sources are editable, each provider reports a bitmask of what it supports, and the UI enables or greys out actions accordingly. In a script the same flags let you check before attempting an edit that will otherwise fail with a bare False.
The URI is the other half of the contract, and it is where most confusion lives, because each provider defines its own grammar. ogr takes a path with optional pipe-separated options. delimitedtext takes a file:// URL with query parameters. postgres takes a key-value connection string, which is why QgsDataSourceUri exists — hand-assembly gets the quoting wrong. memory takes a schema definition, which is genuinely elegant: the URI is the layer.
Finally, provider registration is why nothing works before initQgis(). Most providers — postgres, delimitedtext, WFS, spatialite among them — are loaded during application initialisation; after it, this installation listed 34. Touching the registry before the application exists is worse than an empty list: it loads a partial registry of 17 and keeps it, so postgres and delimitedtext stay missing for the rest of the process (why a layer fails to load has the run). Printing the list is the fastest way to tell "my environment is not ready" from "my URI is wrong" — two problems with identical symptoms.
Layers that never raise, and providers that differ
ogris not one format: it is GDAL's whole vector driver set behind one key, so its behaviour — and its capabilities — vary by file type.QgsVectorLayernever raises: always checkisValid(); the constructor returns an object either way.- Provider keys are case-insensitive in practice, but the registry lists them as
WFS,OAPIF,wmsandpostgres; copy them as listed so a search of your code finds them. setSubsetStringis not the same as a feature request: it changes the layer persistently, including its feature count and extent.- Memory layers are not saved with the project: a reopened project had the layer and none of its features. There is no project option that changes that in QGIS 4.2; write the data to a file.
- A GeoPackage open in the GUI can block a writer: SQLite locking, especially on Windows.
querySublayersreplaces older sublayer APIs: it is provider-agnostic and the right way to enumerate a container.
Internal links
- PyQGIS Layer Fails to Load (isValid() Returns False): How to Fix It
- How to Load and Write PostGIS Layers from PyQGIS
- How to Add, Edit and Delete Features with PyQGIS
- How to Run Field Calculator Expressions from PyQGIS
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- The PyQGIS API Map: Which Class Does What
- What GDAL and OGR Actually Are (and Why Everything Depends on Them)
FAQ
What is a QGIS data provider?
The component that connects a layer to its data source. It fetches features, reports the schema and CRS, applies edits, and declares what it is capable of. The provider key is the third argument to QgsVectorLayer.
Which provider key should I use?
ogr for vector files, gdal for rasters, delimitedtext for CSV with coordinates, postgres for PostGIS, memory for scratch layers, WFS, OAPIF and wms for services.
Why is my layer invalid with the right path?
Often the wrong provider key or the wrong URI grammar for that provider — a bare path passed to delimitedtext, or a misspelt |layername=. Don't rely on dataProvider().error().message(): it was empty for every failure tested here. The QGIS message log named the cause where there was one, and a wrong provider key left no message anywhere.
How do I know whether a layer can be edited?
Check layer.dataProvider().capabilities() against the flags you need. Some providers are read-only, and a WFS layer may not support transactions.
What is the difference between reload() and triggerRepaint()?
reload() asks the provider to re-read the source, which is what you need after an external change. triggerRepaint() only redraws what is already loaded.
Does setSubsetString run on the server?
For postgres, yes: the server log showed it arriving as the WHERE clause of both the count query and the feature cursor. For ogr it goes to GDAL, in the driver's own SQL dialect, and for a delimitedtext CSV the provider evaluates it itself.
Why is the provider list empty in my script?
Because QgsApplication.initQgis() has not run — or because something asked for the registry before it did. Queried before the application existed, the registry loaded 17 providers without postgres or delimitedtext and kept that list for the rest of the process; queried after, it listed 34. Create and initialise the application before anything else touches QGIS.