Generate XYZ Tiles and MBTiles in PyQGIS
A carefully styled QGIS map is only useful to people who have QGIS. Rendering it into a tile pyramid — thousands of small PNG or JPEG images at successive zoom levels, addressed as z/x/y — turns it into something any web map library, mobile app or offline field tool can display. QGIS does the rendering with its own renderer, so labels, blending, symbol layers and everything else you styled come out exactly as they look on the canvas.
This recipe belongs to Map Canvas Control and Image Export in PyQGIS. It estimates the size of a tile job before starting it, generates a directory of tiles and an MBTiles file, controls which layers are drawn, avoids cut labels at tile edges, and loads the output back to check it.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- A project styled the way the tiles should look. Tiles are rendered in Web Mercator (EPSG:3857); the project CRS does not need to be, but check the styling in 3857 first, because line widths and labels in map units change appearance with the projection.
- Disk space for the output, and a realistic idea of the deepest zoom anyone needs.
Estimate the job before running it
Tile counts grow by a factor of four per zoom level, and render time grows with them. A few lines of arithmetic prevent starting a job that would run for a day.
import math
from qgis.core import (
QgsProject, QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsRectangle,
)
def tile_range(lon, lat, z):
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
lat_r = math.radians(max(min(lat, 85.0511), -85.0511))
y = int((1.0 - math.asinh(math.tan(lat_r)) / math.pi) / 2.0 * n)
return min(max(x, 0), n - 1), min(max(y, 0), n - 1)
def estimate(extent_4326, zmin, zmax, seconds_per_tile=0.08, kb_per_tile=18):
total = 0
for z in range(zmin, zmax + 1):
x0, y1 = tile_range(extent_4326.xMinimum(), extent_4326.yMinimum(), z)
x1, y0 = tile_range(extent_4326.xMaximum(), extent_4326.yMaximum(), z)
count = (x1 - x0 + 1) * (y1 - y0 + 1)
total += count
print(f"z{z:>2}: {count:>8,} tiles")
print(f"total {total:,} tiles ≈ {total * seconds_per_tile / 3600:.1f} h, "
f"{total * kb_per_tile / 1024:.0f} MB")
return total
area = QgsProject.instance().mapLayersByName("city_boundary")[0]
to_4326 = QgsCoordinateTransform(area.crs(), QgsCoordinateReferenceSystem("EPSG:4326"),
QgsProject.instance())
extent = to_4326.transformBoundingBox(area.extent())
estimate(extent, 10, 17)
Breakdown: The tile arithmetic is the standard slippy-map formula, clamped to the latitude limit of Web Mercator. Render time per tile depends entirely on the project — a simple vector map renders dozens of tiles per second, a map with heavy labelling and a hillshade far fewer — so time a small run at your deepest zoom and put the real number into seconds_per_tile. Each extra zoom level roughly quadruples the total, which is why stopping one level earlier is the single biggest saving available.
Render tiles to a directory
native:tilesxyzdirectory writes a z/x/y folder tree and, optionally, a small HTML viewer. It renders the layers visible in the project, in their current style.
import processing
from qgis.core import QgsProcessingFeedback
class PrintFeedback(QgsProcessingFeedback):
def setProgress(self, p):
super().setProgress(p)
print(f"\r{p:5.1f}%", end="")
result = processing.run("native:tilesxyzdirectory", {
"EXTENT": area.extent(),
"ZOOM_MIN": 10,
"ZOOM_MAX": 16,
"DPI": 96,
"BACKGROUND_COLOR": None,
"TILE_FORMAT": 0,
"QUALITY": 75,
"METATILESIZE": 4,
"TILE_WIDTH": 256,
"TILE_HEIGHT": 256,
"TMS_CONVENTION": False,
"OUTPUT_DIRECTORY": "/srv/tiles/city_basemap",
"OUTPUT_HTML": "/srv/tiles/city_basemap/index.html",
}, feedback=PrintFeedback())
print("\ndone:", result["OUTPUT_DIRECTORY"])
Breakdown: EXTENT accepts a rectangle in the layer's CRS and is reprojected internally. TILE_FORMAT 0 is PNG, which supports transparency and suits vector cartography; 1 is JPEG, far smaller for imagery and hillshades but with no transparency, which is why QUALITY only applies to it. A BACKGROUND_COLOR of None keeps transparent backgrounds so the tiles can overlay another basemap. TMS_CONVENTION flips the y axis for the older TMS scheme; leave it false for the XYZ convention every modern web library uses. Parameter names occasionally gain additions between releases, so print processing.algorithmHelp("native:tilesxyzdirectory") once on your version.
Clean labels with metatiles
Rendering each 256-pixel tile on its own makes the labelling engine place labels per tile, so a street name near an edge is cut in half or appears in two neighbouring tiles. METATILESIZE renders a block of tiles as one image and slices it, so labels are placed across tile boundaries.
A metatile size of 4 renders 1024 × 1024 pixel images and slices them into sixteen tiles, which fixes almost every edge artefact and is usually faster overall because the renderer's setup cost is shared. Larger sizes use more memory per render; 8 is reasonable for label-heavy maps, and anything above 20 rarely helps. Labels can still be cut at metatile boundaries, so in the project's label settings turn off Show partial labels and give important label layers a little priority so they are placed away from edges where possible — the placement controls from controlling label placement and collisions apply unchanged.
Choose which layers are drawn
The tile algorithms draw the layers visible in the project. Rather than toggling visibility by hand before every run, apply a map theme that defines exactly the tile basemap, render, and restore the user's view.
from qgis.core import QgsMapThemeCollection
project = QgsProject.instance()
themes = project.mapThemeCollection()
root, model = project.layerTreeRoot(), iface.layerTreeView().layerTreeModel()
saved = QgsMapThemeCollection.createThemeFromCurrentState(root, model)
themes.insert("_before_tiles", saved)
try:
themes.applyTheme("tile_basemap", root, model)
processing.run("native:tilesxyzmbtiles", {
"EXTENT": area.extent(), "ZOOM_MIN": 10, "ZOOM_MAX": 16, "DPI": 96,
"BACKGROUND_COLOR": None, "TILE_FORMAT": 0, "QUALITY": 75,
"METATILESIZE": 4, "TILE_WIDTH": 256, "TILE_HEIGHT": 256,
"OUTPUT_FILE": "/srv/tiles/city_basemap.mbtiles",
})
finally:
themes.applyTheme("_before_tiles", root, model)
themes.removeMapTheme("_before_tiles")
Breakdown: Capturing the current state as a temporary theme and restoring it in finally leaves the project exactly as it was, even if rendering fails half-way. The tile_basemap theme itself is created once, by hand or in code, as described in creating a map theme. native:tilesxyzmbtiles takes the same rendering parameters and writes a single SQLite file instead of a directory tree — much easier to copy, sync to a mobile device or serve, because it is one file instead of hundreds of thousands. This snippet uses iface, so it runs in QGIS Desktop; in a headless script, set visibility on the layer tree nodes directly instead.
Check the output
Load the result back into QGIS to confirm it looks right at a few zoom levels before publishing it.
from qgis.core import QgsRasterLayer
mbtiles = QgsRasterLayer("/srv/tiles/city_basemap.mbtiles", "tiles (mbtiles)", "gdal")
print("mbtiles valid:", mbtiles.isValid(), mbtiles.extent())
xyz_uri = ("type=xyz&url=file:///srv/tiles/city_basemap/%7Bz%7D/%7Bx%7D/%7By%7D.png"
"&zmin=10&zmax=16")
directory = QgsRasterLayer(xyz_uri, "tiles (directory)", "wms")
print("directory valid:", directory.isValid())
for layer in (mbtiles, directory):
QgsProject.instance().addMapLayer(layer)
Breakdown: GDAL reads MBTiles directly as a raster. A directory of tiles loads through the XYZ connection type of the wms provider with a file:// URL template; the placeholders are percent-encoded because the whole string is itself a URI — the same pattern used for online basemaps in adding an XYZ tile basemap. Setting zmax stops QGIS requesting tiles deeper than those generated, which would otherwise show as blank. Zoom to the edges of the extent and to dense label areas at the highest zoom; that is where problems appear.
QGIS version compatibility
native:tilesxyzdirectory and native:tilesxyzmbtiles have existed since QGIS 3.8. TILE_WIDTH and TILE_HEIGHT were added in 3.10 for high-DPI tiles, and TMS_CONVENTION in 3.12. Later releases added options such as antialiasing control; unknown parameters are ignored and missing ones take defaults, so check algorithmHelp on your version. For vector tiles rather than rendered images, native:writevectortiles_mbtiles is the separate algorithm to use.
Troubleshooting
- Tiles are blank. No layers are visible, or the extent lies outside Web Mercator's latitude range.
- Labels are cut at edges.
METATILESIZEis 1; raise it to 4 or 8. - Lines look thinner than on the canvas. Symbol widths are in map units that scale differently in EPSG:3857; use millimetres or points.
- The job runs for hours. The zoom range is too deep; estimate first and stop a level earlier.
- Tiles appear upside down in a viewer.
TMS_CONVENTIONdoes not match what the viewer expects.
Conclusion
Estimate tile counts and render time before starting, render with a metatile size of 4 or more so labels survive tile edges, and control content with a map theme applied and restored around the render. Write MBTiles when the output must travel as one file and a directory when a web server will serve it, then load the result back and inspect it at the deepest zoom before anyone else sees it.
Frequently Asked Questions
Should I use raster tiles or vector tiles? Raster tiles reproduce QGIS cartography exactly and work everywhere. Vector tiles are smaller and restylable on the client, but the style must be rebuilt in the client's format.
How do I make retina tiles?
Set TILE_WIDTH and TILE_HEIGHT to 512 and DPI to 192, then tell the web map the tiles are high resolution.
Can I update only part of an existing tile set? Render a smaller extent into the same directory; tiles are overwritten individually. MBTiles files can be updated the same way.
Can this run on a server without a display?
Yes. Load the project headless and call the algorithms, or use qgis_process run native:tilesxyzmbtiles.