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.

How a tile service addresses the worldAt zoom zero the world is one tile. Each further zoom level splits every tile into four, so zoom level two is a four by four grid. The URL template substitutes the zoom level and the tile column and row. The current map view overlaps only a handful of tiles, and those are the only ones requested.Each zoom level quadruples the tile countz=01 tilez=1, 4 tilesz=2, 16 tiles4 in viewthe URL templatetiles.example.org /z / x / y .pngsubstituted per tile, one request eachZoom 19 over a whole city is millions of tilesthe scale you export at decides how many requests you make — check before scheduling it

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.

Screen, poster, and the request count between themRendering a city view on screen requests a few dozen tiles. The same view exported as an A3 sheet at 300 dots per inch requests roughly a thousand. An A0 poster at the same resolution requests tens of thousands. The bar lengths compare the three, with a note that free services publish usage limits well below the largest case.The same map, three output sizes, three very different billsscreen viewabout 40 tilesA3 at 300 dpiabout 1 000 tilesA0 at 300 dpitens of thousands of tilesA nightly poster export can exceed a public service's whole daily allowance

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.

Attribution that follows the layerRights recorded in the layer metadata are read by a layout label through the layer property expression function, so the credit appears on every map that uses the layer. A credit typed directly into a text box has to be remembered on every new layout, and goes stale when the base map is swapped.Store the credit where the layer is, not where the map islayer metadata rightsset once, in the scriptlayout label expressionreads the layer propertyevery exported mapcarries the credittyped into a text box insteadforgotten on the next layout, and stale when the base map changes 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 versionPythonNotes
3.22 LTR3.9type=xyz, zoom limits, tilePixelRatio and saved connections all present.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12Identical 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 with quote() and try again.
  • The map is blank at close zoom. zmax is 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.