List and Load GeoPackage Sublayers in PyQGIS
A shapefile is one layer. A GeoPackage, an Esri File Geodatabase, a KML with folders, a multi-sheet spreadsheet or a NetCDF with several variables is a container, and opening the file only tells QGIS where to look — not which of the forty things inside it you want. Hard-coding |layername=roads works until the data supplier renames the table; iterating what is actually there works every time.
This recipe belongs to Layer Data Sources & Formats in PyQGIS. It uses the provider registry's sublayer query to list a container's contents, loads them selectively, and deals with the containers that mix vector tables, rasters and non-spatial tables in one file.
Prerequisites
- QGIS 3.40 LTR or newer.
querySublayerswas added in 3.22; the oldersubLayers()approach is shown in the compatibility notes. - A container file — the examples use a GeoPackage, but File Geodatabases, KML/KMZ, XLSX and NetCDF are queried the same way.
List what a container holds
QgsProviderRegistry.querySublayers asks every provider that can read the file to report what it finds, and returns one QgsProviderSublayerDetails per sublayer. A GeoPackage therefore comes back with vector tables from the ogr provider and raster tables from gdal in a single list.
from qgis.core import Qgis, QgsProviderRegistry, QgsWkbTypes
path = "/data/deliveries/city_base.gpkg"
registry = QgsProviderRegistry.instance()
details = registry.querySublayers(path)
for d in details:
is_vector = d.type() == Qgis.LayerType.Vector
geom = QgsWkbTypes.displayString(d.wkbType()) if is_vector else "raster"
print(f"{d.providerKey():<6} {d.name():<20} {geom:<16} {d.featureCount()}")
Breakdown: Each details object carries the provider key, the layer type, the table name, the geometry type for vector sublayers, and a feature count — plus the full uri() that opens that one sublayer, so you never assemble |layername= strings yourself. Internal tables such as layer_styles, gpkg_metadata and R-tree index tables are not reported, which is what you want. For a raster-only container like a multi-subdataset NetCDF, the list contains only gdal entries, one per variable.
A feature count of -1 means "not counted". Some formats report counts cheaply from metadata; others would have to read every row, and by default the query does not do that for them.
Choose how much work the query does
Flags trade speed for detail. For a picker that just needs names, a fast scan is enough; for an inventory report that needs counts, ask for them explicitly.
fast = registry.querySublayers(path, Qgis.SublayerQueryFlag.FastScan)
print([d.name() for d in fast])
full = registry.querySublayers(path, Qgis.SublayerQueryFlag.CountFeatures)
for d in full:
print(d.name(), d.featureCount())
Breakdown: On a GeoPackage the difference is small, because table names, geometry types and counts all live in metadata tables. On an Esri File Geodatabase with a few hundred feature classes, or a KML with thousands of placemarks spread across folders, the default query can take several seconds and a counting query much longer. The fast scan is also the right choice before the file is known to be valid — it will not try to read contents that might be corrupt.
Load everything, or a filtered set
toLayer turns a details object into a ready layer with the correct provider, so loading every vector table is a short loop. A filter on the details — type, geometry, name pattern — picks a subset without ever opening the tables you skip.
import re
from qgis.core import QgsProject, QgsProviderSublayerDetails
project = QgsProject.instance()
options = QgsProviderSublayerDetails.LayerOptions(project.transformContext())
wanted = [
d for d in registry.querySublayers(path)
if d.type() == Qgis.LayerType.Vector
and d.wkbType() != Qgis.WkbType.NoGeometry
and not re.match(r"^(tmp|bak)_", d.name())
]
group = project.layerTreeRoot().insertGroup(0, "city_base")
for d in sorted(wanted, key=lambda d: d.name()):
layer = d.toLayer(options)
if layer is None or not layer.isValid():
print("skipped", d.name())
continue
project.addMapLayer(layer, False)
group.addLayer(layer)
Breakdown: Passing the project's transform context in LayerOptions keeps datum transformations consistent with the rest of the project. addMapLayer(layer, False) registers the layer without putting it at the top of the layer tree, and group.addLayer places it inside a group named after the container — the pattern from organising the layer tree. Excluding NoGeometry keeps attribute-only tables out of the map; load those separately if they will be used in layer relations or joins. Sorting by name gives a predictable layer order regardless of how the tables were created.
Load rasters and styles from the same file
Raster sublayers load through the same toLayer call and come back as QgsRasterLayer. GeoPackages can also carry a default style per table in layer_styles, which QGIS applies automatically when the layer is created — so a container prepared by a colleague arrives styled.
for d in registry.querySublayers(path):
if d.type() != Qgis.LayerType.Raster:
continue
raster = d.toLayer(options)
project.addMapLayer(raster, False)
group.insertLayer(-1, raster)
roads = project.mapLayersByName("roads")[0]
count, ids, names, descriptions, error = roads.listStylesInDatabase()
print(count, names)
if count:
qml, err = roads.getStyleFromDatabase(ids[0])
print("style source:", names[0], "ok" if not err else err)
Breakdown: group.insertLayer(-1, …) appends rasters below the vector layers so they draw underneath. listStylesInDatabase returns how many styles are stored for the layer's table, along with their ids and names; the first entry of the list is the one marked default when one exists. getStyleFromDatabase returns the QML text, which you can apply to a different layer with QgsMapLayerStyle or save to disk — useful for moving a style out of a container into a QML file.
Inventory a folder of deliveries
Data suppliers rarely send one container. A monthly delivery might be a dozen GeoPackages and a File Geodatabase, and the first question is always the same: what arrived, and what changed since last month? A fast scan across the folder answers it in seconds and produces a table that can be diffed against the previous delivery.
import csv
from pathlib import Path
def inventory(folder):
rows = []
for item in sorted(Path(folder).iterdir()):
if item.suffix.lower() not in {".gpkg", ".gdb", ".sqlite", ".kml", ".kmz"}:
continue
for d in registry.querySublayers(str(item), Qgis.SublayerQueryFlag.CountFeatures):
is_vector = d.type() == Qgis.LayerType.Vector
rows.append({
"container": item.name,
"sublayer": d.name(),
"kind": QgsWkbTypes.displayString(d.wkbType()) if is_vector else "raster",
"features": d.featureCount(),
})
return rows
current = inventory("/data/deliveries/2026-09")
with open("/data/deliveries/2026-09/inventory.csv", "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["container", "sublayer", "kind", "features"])
writer.writeheader()
writer.writerows(current)
previous = {(r["container"], r["sublayer"]) for r in
csv.DictReader(open("/data/deliveries/2026-08/inventory.csv"))}
added = [r for r in current if (r["container"], r["sublayer"]) not in previous]
print("new sublayers this month:", [f'{r["container"]}:{r["sublayer"]}' for r in added])
Breakdown: A File Geodatabase is a folder, so iterdir picks it up by its .gdb suffix just like a file. Counting features is worth the extra time here, because a table that shrank from 18,000 features to 40 is the kind of change nobody announces and everybody needs to know about. Keying the comparison on container and sublayer name catches both new and renamed tables — a rename shows up as one table added and one missing, which is exactly the situation that breaks hard-coded scripts. The CSV is also a useful attachment to a delivery acceptance note.
QGIS version compatibility
querySublayers, QgsProviderSublayerDetails and Qgis.SublayerQueryFlag arrived in QGIS 3.22. On earlier releases, open the container once and use layer.dataProvider().subLayers(), which returns strings joined by QgsDataProvider.sublayerSeparator() in the form index!!::!!name!!::!!count!!::!!geometry type. Qgis.LayerType and Qgis.WkbType are the 3.30+ spellings of QgsMapLayerType and QgsWkbTypes.Type; the QGIS 4 series only accepts the scoped forms.
Troubleshooting
- An empty list. The path is wrong, or no provider recognises the format. Check
os.path.existsand try the file in the Data Source Manager. - Geometry type reported as Unknown. A fast scan was used, or the table genuinely has mixed geometry. Query without
FastScan. toLayerreturns an invalid layer. The table exists but cannot be read — often a File Geodatabase feature class using a format your GDAL build does not support.- Loaded layers appear unstyled. The styles are stored under a different table name, or the default flag is not set in
layer_styles. - KML folders all appear as one layer. Some KML files flatten folders; query with the
LIBKMLdriver available for per-folder sublayers.
Conclusion
Never guess what a container holds. Query it, filter the details by type, geometry and name, and load only what passes — through toLayer, so the right provider and URI are chosen for you. Use a fast scan for pickers, counts for inventories, and group what you load under the container's name so the project stays readable.
Frequently Asked Questions
Does this work for File Geodatabases?
Yes. Pass the .gdb folder path; each feature class and table is reported as a sublayer.
How do I get the URI for one sublayer without loading it?d.uri() returns it, ready for QgsVectorLayer(d.uri(), d.name(), d.providerKey()).
Can I query a remote GeoPackage?
Over /vsicurl/ yes, though each query reads parts of the file over HTTP — prefer a fast scan.
Why are some tables missing?
Internal and index tables are hidden on purpose. A user table can also be hidden if it is not registered in gpkg_contents.