Add an XYZ Tile Basemap in PyQGIS
A base map is the cheapest cartographic improvement available: analysis floating in white space becomes a map the moment there is a street network underneath it. XYZ tile services — the same slippy-map tiles every web map uses — are the usual source, and PyQGIS loads them through the WMS provider with a URI that looks nothing like a WMS one.
This recipe belongs to Web Services and Remote Data in PyQGIS. It covers the URI format, zoom limits, high-resolution tiles, saving connections for reuse, attribution, and the resolution question that decides whether your nightly export is a good citizen or an accidental denial of service.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A tile URL template containing the
{z},{x}and{y}placeholders, and permission to use it. - A project — the base map is usually the first thing added, so Working with QGIS Projects in PyQGIS is a useful companion.
Add the layer
from qgis.core import QgsRasterLayer, QgsProject
url = "https://tiles.example.org/streets/{z}/{x}/{y}.png"
uri = f"type=xyz&url={url}&zmin=0&zmax=19"
basemap = QgsRasterLayer(uri, "Streets", "wms")
if not basemap.isValid():
raise RuntimeError("tile layer invalid — check the URL template")
project = QgsProject.instance()
project.addMapLayer(basemap, False)
project.layerTreeRoot().insertLayer(-1, basemap) # bottom of the panel
Breakdown: The provider is wms even though this is not a WMS — the WMS provider implements every tiled source QGIS supports, and type=xyz selects the tile behaviour. zmin and zmax bound the levels QGIS will request; without them it happily asks for zoom 22 from a service that stops at 18 and renders a blank map at close range. Adding with addToLegend as False and then inserting at index -1 puts the base map at the bottom of the layer tree, which is where a base map belongs — added the usual way it lands on top and hides everything.
If the URL itself contains a query string, percent-encode the ampersands, or the URI parser will treat them as separators:
from urllib.parse import quote
raw = "https://tiles.example.org/tiles?style=light&lang=en&z={z}&x={x}&y={y}"
uri = f"type=xyz&url={quote(raw, safe='')}&zmax=18"
Breakdown: quote(raw, safe='') escapes everything including the slashes and braces, which the provider decodes before substituting the tile coordinates. This is the single most common reason a tile URL that works in a browser produces an invalid layer in QGIS: the first & inside the URL silently ends the url parameter.
High-resolution tiles and interpolation
Services that offer retina tiles serve images at twice the nominal size, which on a modern display is the difference between crisp and slightly soft.
uri = ("type=xyz&url=https://tiles.example.org/streets/{z}/{x}/{y}@2x.png"
"&zmin=0&zmax=19&tilePixelRatio=2")
Breakdown: tilePixelRatio=2 tells QGIS that each returned image covers the same ground as a standard 256-pixel tile but contains 512 pixels, so it scales them correctly instead of rendering the map at half the intended zoom. Getting this wrong is visually obvious: labels come out either twice the expected size or half of it. Where a service offers both, the retina variant costs roughly four times the bandwidth for the same view, which is worth weighing in a batch job.
Save a connection the user can reuse
A connection stored in settings appears in the browser panel under XYZ Tiles, so a plugin that ships a recommended base map should register one rather than adding a loose layer.
from qgis.core import QgsSettings
settings = QgsSettings()
key = "qgis/connections-xyz/Council base map"
settings.setValue(f"{key}/url", "https://tiles.example.org/streets/{z}/{x}/{y}.png")
settings.setValue(f"{key}/zmin", 0)
settings.setValue(f"{key}/zmax", 19)
settings.setValue(f"{key}/authcfg", "a1b2c3d")
Breakdown: The settings tree under qgis/connections-xyz is what the browser panel reads, so writing these four keys makes the service appear for the user without any further code, in every project they open. authcfg refers to a stored authentication configuration, which is how a keyed service stays usable without the key appearing in any project file. This is application-scope state rather than project state — the distinction covered in Store Plugin Settings with QgsSettings — so remember that it persists across projects and should be removed when your plugin is uninstalled.
Attribution, and being a good citizen
Almost every public tile service requires visible attribution, and QGIS stores it on the layer so layouts can print it.
metadata = basemap.metadata()
metadata.setRights(["© Example Mapping contributors"])
basemap.setMetadata(metadata)
Breakdown: Rights set here appear in the layer properties and can be pulled into a layout label with the layer_property() expression function, which means the credit follows the layer rather than living in a text box somebody forgets to update. Attribution is a licence condition, not a courtesy — a batch of exported maps missing it is a licence breach repeated a hundred times.
Alongside it, check the service's rate limits before scheduling anything: the polite pattern for repeated production is to host your own tiles or use a paid endpoint, and to keep the public services for interactive work.
Swap base maps as the user zooms
One base map rarely suits every scale: a light political map reads well at regional zoom, aerial imagery only earns its bandwidth close in. Scale-based visibility lets a project carry both and show the right one automatically.
def add_basemap(name, url, min_scale, max_scale, zmax=19):
layer = QgsRasterLayer(f"type=xyz&url={url}&zmin=0&zmax={zmax}", name, "wms")
if not layer.isValid():
raise RuntimeError(f"{name}: invalid tile URI")
layer.setScaleBasedVisibility(True)
layer.setMinimumScale(min_scale) # zoomed-out limit
layer.setMaximumScale(max_scale) # zoomed-in limit
project.addMapLayer(layer, False)
project.layerTreeRoot().insertLayer(-1, layer)
return layer
add_basemap("Overview", overview_url, 50000000, 100000)
add_basemap("Imagery", imagery_url, 100000, 500)
Breakdown: Each layer declares the scale window in which it is drawn, and the windows meet at 1:100 000 so exactly one is visible at any zoom — leave a gap and the map goes blank in between, which is a confusing bug to diagnose from a screenshot. Both are inserted at the bottom of the tree in the order added, so the more detailed imagery sits above the overview, which matters at the boundary scale if the windows overlap slightly. Wrapping the whole thing in a function keeps a project-building script readable, and makes the scale windows visible as data rather than buried in repeated calls.
A related trick for print work: set layer.setBlendMode() to multiply on a shaded-relief tile layer so it darkens the layers below rather than covering them, which produces a hillshaded map without any raster processing at all.
QGIS version compatibility
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | type=xyz, zoom limits, tilePixelRatio and saved connections all present. |
| 3.28 LTR | 3.9 | Identical. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Identical URI handling; vector tile sources gained features, which do not affect raster XYZ layers. |
Tiles are published in web mercator (EPSG:3857). QGIS reprojects them to any project CRS on the fly, at some cost in sharpness — for print-quality output in a national grid, a WMS that renders server-side in that CRS gives a better result.
Troubleshooting
- The layer is invalid. The URL template is missing a placeholder, or an unencoded
&truncated it. Encode the URL withquote()and try again. - The map is blank at close zoom.
zmaxis higher than the service supports, so QGIS requests levels that return 404. Set it to the documented maximum. - Labels look twice as big as they should. A retina endpoint without
tilePixelRatio=2, or the reverse. - Tiles load slowly and inconsistently. You are being rate-limited. Reduce the export resolution, cache locally, or move to a service intended for the volume.
- The base map covers the analysis. It was added at the top of the tree. Register with
addMapLayer(layer, False)and insert at index-1. - The base map is missing from a scheduled export. Rendering finished before tiles arrived. Export through the layout exporter, which waits for rendering to complete.
Conclusion
An XYZ base map is one URI, provided the URL is encoded, the zoom limits match the service and the pixel ratio matches the tiles. Insert it at the bottom of the layer tree, store the connection in settings if the user will want it again, carry the attribution on the layer, and check what a high-resolution export will actually request before you schedule it every night.
Frequently Asked Questions
Why is the provider wms for an XYZ layer?
Historical: the WMS provider grew to cover all tiled raster sources. The type=xyz parameter is what selects the behaviour.
Can I use a TMS service?
Yes — TMS numbers rows from the bottom rather than the top. Add &tms=1 to the URI, or invert the y placeholder if the service documents it that way.
How do I add a vector tile service instead?
Use the vectortile provider with a similar URL template. Vector tiles are styled client-side and stay sharp at any zoom, but their geometry is generalised for display and should not be measured.
Do tiles get cached between sessions? Yes, in QGIS's network cache, whose size is a setting. It makes repeated work on the same area much faster, and it is not a substitute for a deliberate local copy when you need to work offline.
Can I restrict a base map to certain scales?
Set the layer's scale-based visibility with setScaleBasedVisibility(True) and a minimum and maximum scale, which is often the neatest way to swap between two base maps as the user zooms.