Load a Vector Tile Layer in PyQGIS
Vector tiles sit between a raster basemap and a real vector layer. Like a raster basemap they arrive as pre-cut tiles over HTTP and cost nothing to render at continental scale; unlike one, the tiles contain geometry, so the map can be restyled, labelled in the local language and drawn crisply at any zoom. What they are not is a data source you can run analysis against, and treating them as one is the mistake worth avoiding early.
This recipe belongs to Web Services & Remote Data in PyQGIS. It covers building the URI for XYZ and MBTiles sources, setting the zoom range, applying a MapBox style JSON, reading what is actually in a tile, and where vector tiles stop being appropriate.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer. Vector tile support arrived in 3.14 and matured through 3.16 to 3.20.
- A tile source: an XYZ template URL, or an MBTiles file. Many public services require an API key in the URL.
- Network access, or a local MBTiles file for offline work.
Load an XYZ vector tile source
The layer is created from a URI string of key=value pairs, and the encoding rules matter.
from qgis.core import QgsVectorTileLayer, QgsProject
from urllib.parse import quote
template = "https://tiles.example.org/data/v3/{z}/{x}/{y}.pbf?key=YOUR_KEY"
uri = (
"type=xyz"
f"&url={quote(template, safe='')}"
"&zmin=0&zmax=14"
)
layer = QgsVectorTileLayer(uri, "Base tiles")
if not layer.isValid():
raise RuntimeError("vector tile layer failed to load — check the URL and key")
QgsProject.instance().addMapLayer(layer)
Breakdown: The template URL must be percent-encoded when embedded in the URI, because it contains & and ? characters that would otherwise be read as URI separators — quote(..., safe='') handles that, and forgetting it produces a layer that reports itself valid and fetches nothing. The {z}, {x} and {y} placeholders survive the encoding as literal braces, which is what the provider expects. zmin and zmax must match what the service actually publishes; requesting zoom 18 from a source that stops at 14 produces empty tiles rather than an error.
isValid() on a vector tile layer is weaker than on a file-based layer — it confirms the URI parsed, not that any tile was fetched. A layer that draws nothing with a valid URI usually means a wrong key or a wrong zoom range, and the network log is where the answer is.
Load an MBTiles file
An MBTiles container holds the same tiles offline, which is the right choice for a field laptop or a reproducible script.
uri = "type=mbtiles&url=/data/tiles/region.mbtiles"
layer = QgsVectorTileLayer(uri, "Region tiles")
if not layer.isValid():
raise RuntimeError("MBTiles source did not load")
QgsProject.instance().addMapLayer(layer)
print(layer.sourceMinZoom(), layer.sourceMaxZoom())
Breakdown: No encoding is needed for a plain file path with no query string. sourceMinZoom() and sourceMaxZoom() read the range from the container's metadata table, which is the reliable way to discover it rather than guessing — and worth printing the first time a new file appears, because a mismatch between the declared range and the requested one is the commonest cause of a blank map.
Apply a style
Out of the box a vector tile layer draws with a default style that is deliberately plain. Real styling comes from a MapBox GL style JSON or from QGIS's own renderer.
message, ok = layer.loadDefaultStyle()
if not ok:
message, ok = layer.loadNamedStyle("/data/styles/basemap.json")
if not ok:
print(f"style not applied: {message}")
layer.triggerRepaint()
Breakdown: loadDefaultStyle() picks up a style the service advertises, which many tile providers do. Falling back to a named style file lets a project pin its own appearance; QGIS reads MapBox GL JSON and converts it into its own rule-based renderer, which is a genuine conversion rather than a passthrough — expressions and sprite-based symbols do not always survive. Checking the boolean rather than assuming is the same discipline as applying an SLD style.
Once converted, the renderer is an ordinary QGIS object and can be edited from Python — each sub-layer of the tile source becomes a rule with its own filter, so restyling roads without touching buildings is a matter of finding the right rule.
Labelling from tile attributes
The single best reason to prefer vector tiles over a raster basemap is that the labels are yours. A raster basemap ships place names baked into the pixels in one language at one size; vector tiles ship the names as attributes.
from qgis.core import (
QgsPalLayerSettings, QgsTextFormat, QgsVectorTileBasicLabeling,
QgsVectorTileBasicLabelingStyle, QgsWkbTypes,
)
from qgis.PyQt.QtGui import QColor
settings = QgsPalLayerSettings()
settings.fieldName = "coalesce(\"name:cy\", \"name\")"
settings.isExpression = True
settings.placement = QgsPalLayerSettings.OverPoint
fmt = QgsTextFormat()
fmt.setSize(9)
fmt.setColor(QColor("#17211d"))
settings.setFormat(fmt)
style = QgsVectorTileBasicLabelingStyle()
style.setStyleName("places")
style.setLayerName("place")
style.setGeometryType(QgsWkbTypes.PointGeometry)
style.setMinZoomLevel(6)
style.setMaxZoomLevel(14)
style.setLabelSettings(settings)
labeling = QgsVectorTileBasicLabeling()
labeling.setStyles([style])
layer.setLabeling(labeling)
layer.triggerRepaint()
Breakdown: Vector tile labelling uses its own classes rather than the vector-layer ones, because a label style has to say which sub-layer it applies to and at which zooms — hence setLayerName("place") and the zoom range. The coalesce() expression picks a Welsh name where the tiles carry one and falls back to the default, which is the pattern for any multilingual source and impossible with a raster basemap. setGeometryType() must match what that sub-layer holds, and a mismatch produces no labels with no complaint.
Because the settings object is the ordinary QgsPalLayerSettings, everything from label placement and collision handling applies unchanged — buffers, callouts, obstacle weights and priority all work exactly as they do on a normal layer.
What is actually in the tiles
Tile contents vary by source and by zoom, and there is no schema to consult. Asking the layer is the only reliable route.
from qgis.core import QgsCoordinateTransform, QgsProject
renderer = layer.renderer()
if hasattr(renderer, "styles"):
for style in renderer.styles():
print(style.layerName(), style.filterExpression(), style.minZoomLevel(), style.maxZoomLevel())
Breakdown: After a style is applied, the renderer's styles list names each sub-layer the style knows about, along with the filter and the zoom range it applies at. That is not the same as everything the tiles contain — a style ignores sub-layers it does not draw — but it is usually the practical list. For a definitive answer, open one tile with a MapBox Vector Tile reader outside QGIS, or consult the source's published schema.
When vector tiles are the wrong tool
Three properties of the format rule out whole classes of use.
Geometry is generalised per zoom level, so a building footprint at zoom 12 is not the same shape as at zoom 16 and neither is the surveyed shape. Features are clipped at tile boundaries, so a single road may appear as several partial pieces with duplicated attributes — measuring its length gives an answer that depends on the tile grid. And attributes are whatever the tile author included, typically a name and a class, not the source dataset's full table.
For analysis, get the real data: a WFS layer if the service offers one, or a bulk download. Vector tiles are a basemap that happens to be restylable, and using them as one is entirely correct.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.14 | 3.7 | QgsVectorTileLayer introduced; XYZ and MBTiles sources supported. |
| 3.16 LTR | 3.7 | MapBox GL style JSON conversion added. |
| 3.22 LTR | 3.9 | Labelling from tile attributes improved; sourceMinZoom exposed. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40+ | 3.12 | Vector tile writing supported, so QGIS can generate tiles as well as read them. |
Troubleshooting
- The layer is valid but nothing draws. The URL was not percent-encoded, or the zoom range does not overlap the source's.
- Tiles load at some zooms and not others.
zmin/zmaxare wrong. Read them from the source metadata. - Everything is grey rectangles. No style was applied and the default renderer is drawing raw geometry. Load a style.
- The style loaded but looks wrong. MapBox GL features that QGIS cannot represent were dropped in conversion. Adjust the resulting rules directly.
- Measuring a feature gives the wrong length. Features are clipped at tile boundaries. Use real vector data for measurement.
- The API key is visible in the project file. It is stored in the URI. Use the authentication manager to keep it out.
Conclusion
Percent-encode the template URL, match the zoom range to the source, load a style before judging the result, and treat the layer as a basemap rather than as data. When measurement or attributes matter, fetch the real dataset instead — the tiles were generalised and clipped on purpose.
Frequently Asked Questions
Can I query a feature by clicking it? Identify works and returns whatever attributes the tile carries, which is usually a name and a class. It is not the source record, and the feature may be one clipped piece of a longer one.
Does QGIS cache tiles? Yes, through the network cache, with size and expiry configurable in the settings. For genuinely offline work, download an MBTiles file rather than relying on the cache.
Can I export vector tiles to a file layer? QGIS 3.40 and newer can write vector tiles, and converting a tile source to a vector layer is possible for a small extent — but the result inherits the generalisation and clipping, so it is a poor substitute for the source data.
How do I keep the API key out of the project file? Store the credential in the authentication database and reference its id in the URI, so the project holds a reference rather than the secret.