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:

  1. The provider key — ogr, gdal, postgres, memory, delimitedtext, WFS, wms — selects the implementation
  2. The URI is provider-specific: a path for ogr, a file:// URL for delimitedtext, a key-value string for postgres
  3. Providers declare capabilities: can they add features, change attributes, do they support transactions?
  4. Providers cache metadata such as the feature count; reload() refreshes it
  5. QgsVectorLayer never raises — always check isValid(), and when it is False read 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

From QgsVectorLayer down through the provider registry to the source. one layer class, many providersQgsVectorLayerrender, style, edit bufferprovider registrykey → implementationQgsVectorDataProviderfetch, schema, writeogr · postgres · memory · wfsthe implementationsthe actual sourcefile, database, serviceThe layer is uniform; the provider is what makes it work spatialworkflow.io
The layer is a uniform interface; the provider is what makes it work for a given source.

Provider keys, capabilities, metadata and filters

Provider keys, their URI forms and typical use. six providers, six URI grammarsURI looks likeeditable?ogrpath|layername=x✓usuallygdalpath to a raster✕nodelimitedtext!file:///…?xField=lon✕nopostgresQgsDataSourceUri()✓yesmemoryPoint?crs=EPSG:4326✓yeswfsurl='…' typename='…'!if the server allowsPassing the wrong grammar gives an invalid layer and no exception spatialworkflow.io
Six providers you will actually use, and the URI each one expects.

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 same area filter set with setSubsetString on a PostGIS layer and a GeoPackage layer, and what came back. one method, a different SQL each timelayer and subsetwhere it ranfeatureCount()postgres: ST_Area(geom) > 100000PostgreSQL, as WHERE926gpkg: ST_Area(geom) > 100000GDAL's SQLite-1, and True returnedgpkg: ST_Area(geometry) > 100000GDAL's SQLite927the GeoPackage names its geometry column geometry; the error went to the console onlyA subset string is passed to the provider untranslated — write it in that provider’s dialect spatialworkflow.io
The runs above, in one table: setSubsetString() returning True does not mean the filter worked.

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.

A database-backed provider compared with a file-backed one, as measured on Rutland parcels. what actually differspostgres✓features read on demand✓count cached until reload()✓subset runs as SQL on the server✓many writers, transactionsogr on a GeoPackage✓features read on demand✓count cached until reload()✓subset in GDAL's SQLite dialect✓one writer at a timeNeither is a snapshot: both saw 3 new rows when iterated, and neither counted them until reload() spatialworkflow.io
The same layer API over a database and over a file — measured, the differences are fewer than the usual live-versus-snapshot story.

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

  • ogr is not one format: it is GDAL's whole vector driver set behind one key, so its behaviour — and its capabilities — vary by file type.
  • QgsVectorLayer never raises: always check isValid(); the constructor returns an object either way.
  • Provider keys are case-insensitive in practice, but the registry lists them as WFS, OAPIF, wms and postgres; copy them as listed so a search of your code finds them.
  • setSubsetString is 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.
  • querySublayers replaces older sublayer APIs: it is provider-agnostic and the right way to enumerate a container.

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.