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.

What is inside one .gpkgA single GeoPackage file is drawn as a container. Inside are roads as a line table, buildings as a polygon table, an orthophoto stored as raster tiles, an inspections table with no geometry, and the layer_styles table. querySublayers reports the first four as separate sublayers with their provider key, type and feature count; layer_styles is an internal table and is not offered as a layer.One file, many layers of different kindscity_base.gpkgroads · LineStringbuildings · Polygonortho_2025 · raster tilesinspections · no geometrylayer_styles · internalquerySublayers(path)ogr vector roads 2 140ogr vector buildings 18 902gdal raster ortho_2025ogr vector inspections 611provider · type · name · feature count

Prerequisites

  • QGIS 3.40 LTR or newer. querySublayers was added in 3.22; the older subLayers() 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.

Fast scan or full detailThe FastScan flag reads just the table list, returns within milliseconds even for a large geodatabase, and may report unknown geometry types and feature counts of minus one. The default query opens each table, which can take seconds on a File Geodatabase with hundreds of feature classes, but returns geometry types. The CountFeatures flag additionally forces exact counts.Pay for detail only when you need itFastScantable names onlytype may be unknowncount = −1for pickers and menusdefaultopens each tablegeometry type knowncount if cheapfor loading by typeCountFeatureseverything aboveplus exact countsmay read every rowfor inventories and QA

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.

Filter the details, not the layersEleven sublayers enter. The vector-type filter removes two rasters. The geometry filter removes three attribute-only tables. The name filter removes two tables prefixed tmp or bak. Four layers remain and are loaded into a city_base group. Rejected sublayers are never opened, so filtering first is cheaper than loading everything and removing layers afterwards.Rejected sublayers are never opened11sublayersvector only−2 rastershas geometry−3 tablesname filter−2 tmp / bak4loadedfiltering details costs nothing; removing loaded layers costs an open each

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.exists and 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.
  • toLayer returns 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 LIBKML driver 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.