---
title: "PMTiles vector tiles in Python and a MapLibre GL JS web map"
description: "Create PMTiles vector tiles in Python with GDAL (no Tippecanoe), compare zoom levels and tile sizes, and serve a MapLibre GL JS map from static hosting."
url: https://www.technicalgis.com/tutorials/vector-tiles-pmtiles-maplibre/
author: TechnicalGIS Team
publisher: TechnicalGIS (https://www.technicalgis.com/about/)
published: 2026-10-19
updated: 2026-10-19
language: en-GB
---

# PMTiles vector tiles in Python and a MapLibre GL JS web map

> Create PMTiles vector tiles in Python with GDAL (no Tippecanoe), compare zoom levels and tile sizes, and serve a MapLibre GL JS map from static hosting.

- Level: Intermediate
- Tested with: Python 3.12
- Source code: [Download scripts](https://www.technicalgis.com/code/vector-tiles-pmtiles-maplibre/)

Series · Part 2 of 2

## Cloud-native vector GIS with Python

1.  [Query Overture Maps with DuckDB spatial and GeoParquet in Python](https://www.technicalgis.com/tutorials/geoparquet-duckdb-overture-maps/)
2.  PMTiles vector tiles in Python and a MapLibre GL JS web map

## Key takeaways

1.  GDAL 3.8+ has a PMTiles driver, and the pyogrio wheel includes it (GDAL 3.12.4 in pyogrio 0.13.0). One write\_dataframe(driver="PMTiles") call turns a GeoDataFrame into vector tiles on Windows, with no Tippecanoe, WSL or Docker.
2.  Each extra zoom level roughly triples the tile count and adds 2–3 MB for 144,965 Bristol buildings: 1.37 MB for zoom 12 alone, 11.25 MB for zooms 12–16. Stop at zoom 14 and let MapLibre overzoom; that kept the buildings at 2.26 MB.
3.  Attributes cost more than geometry: keeping all ten part 1 columns made the zoom-14 tiles 7.39 MB instead of 2.26 MB. A coarser EXTENT=1024 grid saved 21% and SIMPLIFICATION=4 saved 16%.
4.  The whole map is four PMTiles files (3.01 MB, 202 tiles) on static hosting. The browser reads only the byte ranges it needs: 104 KB for the opening view and 353 KB for a street-level view.
5.  Static hosting must answer HTTP Range requests. Python's http.server does not, so the tutorial includes a 50-line serve.py that does; most web servers and object stores already do.

## Before you start

-   Part 1 of this series, or its four GeoParquet files (the script re-runs part 1 if they are missing)
-   Python 3.10+ with geopandas, pyogrio 0.10+ (GDAL 3.8+), pmtiles, pandas, matplotlib and requests
-   A little HTML and JavaScript; Node.js only if you want the optional Playwright screenshots

**Short answer:** to create vector tiles in Python on any platform, write a GeoDataFrame with `pyogrio.write_dataframe(gdf, "buildings.pmtiles", driver="PMTiles", layer="buildings", MINZOOM="12", MAXZOOM="14")`. The pyogrio wheel bundles GDAL 3.12, whose PMTiles driver cuts and encodes the tiles. Put the `.pmtiles` file on any static host that supports HTTP Range requests. In the page, register the `pmtiles` protocol with MapLibre GL JS (`maplibregl.addProtocol("pmtiles", new pmtiles.Protocol().tile)`) and use `pmtiles://…/buildings.pmtiles` as a vector source. You don’t need a tile server, a database or Tippecanoe.

This is part 2 of the two-part series **Cloud-native vector GIS with Python**. [Part 1](https://www.technicalgis.com/tutorials/geoparquet-duckdb-overture-maps/) pulled central Bristol out of the Overture Maps GeoParquet release with DuckDB. Now we turn that data into **PMTiles** vector tiles and an interactive **MapLibre GL JS** map that runs from plain files. The map below is the real result, served from this site as static files.

You will learn how to:

1.  check that your GDAL has the **PMTiles driver**, and write vector tiles from GeoPandas;
2.  choose **zoom levels**, and measure what each one costs in tiles and megabytes;
3.  shrink tiles by dropping **attributes** and changing the **extent** and **simplification**;
4.  build a **MapLibre GL JS** page with a style for several PMTiles sources;
5.  serve it locally with **HTTP Range** support, and see what the browser actually downloads;
6.  host it on **static hosting**, with the right licence notices.

All outputs below are real results from [`pmtiles_vector_tiles.py`](https://www.technicalgis.com/code/vector-tiles-pmtiles-maplibre/), run on 19 October 2026 on Windows 11 with Python 3.12.0, GeoPandas 1.2.0, pyogrio 0.13.0 (GDAL 3.12.4), pmtiles 3.8.1, MapLibre GL JS 5.24.0 and pmtiles.js 4.5.0. The screenshots and download counts come from Playwright 1.64.1 driving Chromium.

## Set up

```bash
pip install geopandas==1.2.0 pyogrio==0.13.0 pyarrow==26.0.0 pmtiles==3.8.1 matplotlib requests
```

GDAL added a read-write PMTiles driver in version 3.8 (November 2023). You don’t need a separate GDAL install: the binary wheels of **pyogrio** and **Fiona** bundle their own GDAL. Check what yours has:

`pmtiles_vector_tiles.py`

```python
import pyogrio

print(pyogrio.__gdal_version_string__)
drivers = pyogrio.list_drivers()
print(drivers.get("PMTiles"), drivers.get("MVT"))
```

```text
3.12.4
rw rw
```

`rw` means GDAL can read and write. The `MVT` driver writes a folder of `.pbf` tiles instead of one archive. If you see `None`, upgrade pyogrio, or install GDAL from conda-forge. The rasterio wheels also bundle GDAL, but rasterio only handles rasters, so use pyogrio or Fiona for vectors.

## Step 1: load part 1’s GeoParquet and keep only what the map needs

```python
from pathlib import Path

import geopandas as gpd

PART1 = Path("../geoparquet-duckdb-overture-maps/output")
b = gpd.read_parquet(PART1 / "bristol_buildings.parquet")
buildings = gpd.GeoDataFrame({"subtype": b["subtype"], "height": b["height"].round(1)}, geometry=b.geometry)
```

```text
buildings 144,965 features  18.86 MB GeoParquet  crs OGC:CRS84
places     20,727 features   1.75 MB GeoParquet  crs OGC:CRS84
water         161 features   0.14 MB GeoParquet  crs OGC:CRS84
hex           758 features   0.08 MB GeoParquet  crs OGC:CRS84
```

Every attribute goes into every tile, so keep only what you’ll style or show in a popup: `subtype` and `height` for buildings, and name, category, confidence, source and licence for places. Step 3 measures why this matters. Tiles are always in web mercator (EPSG:3857), and GDAL reprojects from longitude/latitude for you.

## Step 2: write PMTiles and choose the zoom levels

One call writes a layer to a new archive. GDAL’s creation options are passed as keyword arguments, as strings:

```python
import pyogrio

pyogrio.write_dataframe(buildings, "buildings_z12-14.pmtiles", driver="PMTiles", layer="buildings",
                        encoding="UTF-8", MINZOOM="12", MAXZOOM="14")
```

`encoding="UTF-8"` matters on Windows. Without it, pyogrio may encode text with the Windows code page and fail on names with characters outside it.

The size of a tileset depends mostly on its zoom range. Each zoom level has four times as many tiles as the one before, each covering a quarter of the area at twice the precision. We wrote the buildings five times, always from zoom 12 and up to a higher maximum:

```text
zooms  tiles  size_MB  write_s  largest_tile_KB
12-12      6     1.37     11.6              495
12-13     22     3.38     25.6              495
12-14     77     5.64     39.9              495
12-15    243     8.18     54.8              495
12-16    840    11.25     69.8              495
```

And inside the zoom 12–16 archive, level by level:

```text
    tiles  total_KB  mean_KB  max_KB  tile_width_m  grid_step_m
z
12      6      1368    228.0     495          6096         1.49
13     16      2008    125.5     307          3048         0.74
14     55      2260     41.1     115          1524         0.37
15    166      2543     15.3      46           762         0.19
16    597      3067      5.1      16           381         0.09
```

![Two bar charts. Left: buildings archive size rising from 1.37 MB for zoom 12 only to 11.25 MB for zooms 12 to 16, with write times from 11.6 to 69.8 seconds. Right: tiles per zoom level on a log scale, from 6 tiles at zoom 12 to 597 at zoom 16, with the largest tile shrinking from 495 KB to 16 KB](https://www.technicalgis.com/_astro/01-zoom-size-tradeoff.C-zEcapz_Z1qVMnD.webp)

_144,965 Bristol buildings. Every extra zoom level adds 2–3 MB and about 15 s of writing; tile counts grow about 3× per level for this area._

What the numbers show:

-   **Each zoom level adds 2–3 MB.** The whole city is in every level, at a finer grid each time. The `grid_step_m` column is the tile width divided by the 4,096-unit extent: 1.49 m at zoom 12, and 0.37 m at zoom 14, which is already finer than the footprints themselves.
-   **Low zooms hit the tile size limit.** GDAL keeps every tile under `MAX_SIZE` (500 KB by default). The zoom-12 tiles were trimmed to 495 KB, and reading them back with `pyogrio.read_dataframe(path, ZOOM_LEVEL="12")` gave 135,534 feature pieces against 147,295 at zoom 14: small buildings were dropped to fit. At this zoom, 145,000 footprints are clutter anyway, which is why the map shows hexagons instead.
-   **High zooms are cheap per tile but add up.** Zoom 16 tiles average 5.1 KB, but there are 597 of them for one city.

MapLibre **overzooms**: when there are no tiles past a source’s `maxzoom`, it scales up the last level. Vector geometry stays sharp, so zoom-14 buildings still look right at zoom 18, and 0.37 m precision is plenty for footprints. So the map uses **zoom 14 only** for buildings, and **2.26 MB** instead of 11.25 MB.

![Map of central Bristol with building footprints in dark grey under a grid of 55 zoom-14 tiles, shaded and labelled by compressed size. Tiles over the dense centre are 80 to 115 KB; tiles at the edges with few buildings are under 5 KB](https://www.technicalgis.com/_astro/02-z14-tile-sizes.0yWQxctG_1pzpiX.webp)

_The 55 zoom-14 building tiles used by the map, labelled with their gzip-compressed size in KB. Dense terraces and the centre cost about 100 KB a tile; parks and edges cost almost nothing._

## Step 3: what attributes and options cost

Next we wrote the buildings at zoom 14 only, with five different settings:

```text
                           variant  size_MB  write_s
                     geometry only     2.12     13.8
           subtype + height (used)     2.26     14.9
             all 10 Part 1 columns     7.39     16.9
     subtype + height, EXTENT=1024     1.79     15.5
subtype + height, SIMPLIFICATION=4     1.90     15.1
```

![Horizontal bar chart of zoom-14 building tileset sizes: geometry only 2.12 MB, subtype and height 2.26 MB, all ten part 1 columns 7.39 MB, EXTENT 1024 1.79 MB, and SIMPLIFICATION 4 1.90 MB](https://www.technicalgis.com/_astro/03-attributes-precision.0aFPy8pT_87kO9.webp)

_The same 144,965 buildings at zoom 14. Two short attributes add 7%; all ten columns, including a unique ID for every building, more than triple the size._

-   **Attributes are the biggest lever.** The ten part 1 columns include Overture’s unique `id` and the source record IDs. Vector tiles store each distinct value once per tile, so short repeated values such as `residential` are almost free, but a unique ID per feature is expensive. Keep IDs out of tiles, and look details up from the GeoParquet file when someone clicks.
-   **`EXTENT=1024`** (the default is 4096) snaps coordinates to a coarser grid: 1.5 m at zoom 14 here. It saved 21%, but square corners start to wobble when you zoom in past 17.
-   **`SIMPLIFICATION=4`** simplifies geometry by 4 grid units before encoding. It saved 16% and is fine for display.

Write times hardly change: almost all the time goes into cutting and encoding geometry, not attributes.

## Step 4: the final tilesets

The map has four layers, each in its own archive:

```python
final = {   # layer: (data, minzoom, maxzoom)
    "hex": (hexes, 10, 13),
    "water": (water, 10, 14),
    "buildings": (buildings, 14, 14),
    "places": (places, 14, 14),
}
for name, (gdf, z0, z1) in final.items():
    pyogrio.write_dataframe(gdf, f"output/web/{name}.pmtiles", driver="PMTiles", layer=name, encoding="UTF-8",
                            MINZOOM=str(z0), MAXZOOM=str(z1), NAME=f"Bristol {name}")
```

```text
             file  features zooms  tiles  size_MB  write_s
      hex.pmtiles       758 10-13     25     0.10      0.3
    water.pmtiles       161 10-14     67     0.07      0.1
buildings.pmtiles    144965 14-14     55     2.26     13.8
   places.pmtiles     20727 14-14     55     0.58      2.0
total: 3.01 MB in 202 tiles (the four GeoParquet inputs are 20.8 MB)
```

## Step 5: the MapLibre GL JS page

The page needs MapLibre GL JS, the `pmtiles` JavaScript library and a style. The script downloads both libraries from unpkg once and serves them with the map, so no third-party CDN is used at run time. The heart of the page is a few lines:

```html
<link rel="stylesheet" href="lib/maplibre-gl.css">
<script src="lib/maplibre-gl.js"></script>
<script src="lib/pmtiles.js"></script>
<script>
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
const map = new maplibregl.Map({ container: "map", style: STYLE, center: [-2.5925, 51.4585], zoom: 12.4 });
</script>
```

The style is plain JSON, so the script builds it as a Python dictionary. Each PMTiles file is a `vector` source with a `pmtiles://` URL, and each layer picks a `source-layer` (the `layer=` name we wrote):

```python
style = {
    "version": 8,
    "sources": {n: {"type": "vector", "url": f"pmtiles://{n}.pmtiles", "attribution": ATTRIBUTION}
                for n in ["hex", "water", "buildings", "places"]},
    "layers": [
        {"id": "background", "type": "background", "paint": {"background-color": "#f3f1ec"}},
        {"id": "water", "type": "fill", "source": "water", "source-layer": "water",
         "paint": {"fill-color": "#9ecae1"}},
        {"id": "hex", "type": "fill", "source": "hex", "source-layer": "hex", "maxzoom": 14.5,
         "paint": {"fill-color": ["interpolate", ["linear"], ["get", "places"],
                                  0, "#fff7ec", 25, "#fdd49e", 75, "#fc8d59", 200, "#d7301f", 500, "#7f0000"]}},
        {"id": "buildings", "type": "fill", "source": "buildings", "source-layer": "buildings", "minzoom": 14,
         "paint": {"fill-color": ["match", ["get", "subtype"],
                                  "residential", "#e8c48f", "commercial", "#e49aa6", "#c9cdca"]}},
        {"id": "places", "type": "circle", "source": "places", "source-layer": "places", "minzoom": 15,
         "paint": {"circle-color": ["match", ["get", "group"], "food_and_drink", "#c21f3a", "#6b6b6b"]}},
    ],
}
```

The full script also fades the hexagons out between zoom 13.5 and 14.5, adds a legend, and opens a popup when you click a place. There is no basemap: the water, buildings and hexagons are the map. To add streets, put a Protomaps or OpenFreeMap basemap source underneath, which is another PMTiles file or style URL.

The script writes two copies of the page: `output/web/index.html`, with relative paths, and `output/map.html`, pointing at the copy hosted on this site. The whole `output/web/` folder is **4.19 MB**: the four tilesets, MapLibre (1.06 MB of JavaScript) and the page.

## Step 6: serve it with Range requests

PMTiles needs a server that answers `Range` headers with `206 Partial Content`. Python 3.12’s `python -m http.server` ignores them and sends the whole file every time, and pmtiles.js then fails to read the archive. The tutorial includes [`serve.py`](https://www.technicalgis.com/code/vector-tiles-pmtiles-maplibre/), a `ThreadingHTTPServer` subclass of about 50 lines that adds byte ranges:

```bash
python serve.py          # serving output/web on http://localhost:8000/
```

```bash
curl -s -o NUL -w "%{http_code} %{size_download}\n" -H "Range: bytes=0-16383" http://localhost:8000/buildings.pmtiles
206 16384
```

Here is the map, served as static files from this site:

Live map: four PMTiles files (3 MB) and MapLibre GL JS, all self-hosted. Zoom in past 14 for buildings and past 15 for places; click a place for details. [Open full screen ↗](https://www.technicalgis.com/maps/vector-tiles-pmtiles-maplibre/map.html)

### What the browser downloads

The script `screenshot_map.mjs` opens three views with Playwright, each in a fresh browser context with an empty cache, and adds up the bytes of every `.pmtiles` response:

```text
04-map-overview-hexagons.png: zoom 12.4, 16 range requests to .pmtiles, 104 KB fetched; rendered features {"hex":743,"buildings":0,"places":0}
05-map-buildings-places.png: zoom 15.6, 10 range requests to .pmtiles, 353 KB fetched; rendered features {"hex":0,"buildings":1180,"places":1624}
06-map-popup-detail.png: zoom 17.6, 7 range requests to .pmtiles, 199 KB fetched; rendered features {"hex":0,"buildings":68,"places":207}, clicked "Bershka"
```

![MapLibre map of Bristol at zoom 12.4 showing H3 hexagons shaded from pale cream to dark red by the number of places, darkest in the city centre with a line of darker cells running north, with the River Avon in blue and a legend panel in the top-left corner](https://www.technicalgis.com/_astro/04-map-overview-hexagons.YNiSqBhs_1XXuqD.webp)

_Opening view at zoom 12.4: 743 hexagons drawn from 104 KB of tiles, 3.5% of the 3.01 MB tileset._

![MapLibre map at zoom 15.6 around Broadmead and Cabot Circus with building footprints coloured by subtype (pink commercial, sand residential, grey unknown) and hundreds of small coloured dots for places by category](https://www.technicalgis.com/_astro/05-map-buildings-places.D0WxL8Hm_ZXzOM1.webp)

_Zoom 15.6 over Broadmead: zoom-14 building and place tiles, overzoomed. 353 KB fetched for 1,180 building pieces and 1,624 place dots._

![MapLibre map at zoom 17.6 on a shopping street with large pink commercial buildings and many place dots. A popup reads: Bershka, fashion and apparel store, source Foursquare (Apache-2.0), confidence 0.77](https://www.technicalgis.com/_astro/06-map-popup-detail.Dj-y72_x_Z1XMkoG.webp)

_Zoom 17.6, three zoom levels past the stored tiles, with the popup for a place. The per-record source and licence from part 1 travel in the tiles._

Each view loads a few hundred kilobytes, not the 3 MB tileset. pmtiles.js first reads the first 16 KiB of each archive (the header and root directory). After that, every tile is one Range request for exactly its bytes. At zoom 17.6 the browser still fetches zoom-14 tiles, scaled up, which is why that view costs less than the zoom 15.6 one.

## Step 7: host it on static hosting

Copy `output/web/` to any host that supports Range requests: GitHub Pages, Netlify, Cloudflare Pages, Apache or Nginx, or an object store such as Amazon S3, Azure Blob Storage or Cloudflare R2. This site uses ordinary shared hosting with Apache. Things to check:

-   **Range requests.** Request the first bytes with `curl -H "Range: bytes=0-99" -I <url>` and look for `206` and `Content-Range`.
-   **CORS.** If the tiles are on a different domain from the page (say a bucket), allow your site’s origin for `GET` and `HEAD`, and expose the `Content-Range` and `ETag` headers.
-   **No extra compression.** GDAL already gzips each tile inside the archive. Don’t let the server compress `.pmtiles` responses, or Range offsets may break; serve them as `application/octet-stream`.
-   **Caching.** Archives are replaced, not edited, so give them a long cache lifetime and change the file name when you rebuild.

For bigger areas, a whole country or the planet, put the archive in object storage or behind a CDN. Protomaps documents a Cloudflare Worker that turns a PMTiles file in R2 into ordinary `/{z}/{x}/{y}.mvt` URLs, for clients that don’t speak PMTiles.

## Licences and attribution

The tiles are a derived database of Overture data, so part 1’s licences carry over:

-   **Buildings and water** are ODbL 1.0. The tilesets are a _produced work_ when shown as a map, but anyone can download the `.pmtiles` files, so treat them as a derived database: share them under the ODbL, with credit to OpenStreetMap contributors and Overture.
-   **Places** keep their source licence (CDLA-Permissive-2.0, Apache-2.0 or CC0). The `licence` attribute is in every tile, and the popup shows it.
-   The **attribution** for each source is in the style, so MapLibre shows “© OpenStreetMap contributors, Overture Maps Foundation (release 2026-09-23.1)” on the map.
-   **MapLibre GL JS** (BSD-3-Clause) is self-hosted with its licence file next to it. **pmtiles.js** (BSD-3-Clause) is inlined, with a comment naming it.

## Limitations and caveats

-   **GDAL doesn’t generalise as much as Tippecanoe.** It drops features to meet `MAX_SIZE` and simplifies to the tile grid, but it doesn’t merge small polygons or thin points cleverly. For low-zoom views of dense data, aggregate first, as the hexagons do here.
-   **One layer per archive.** Multi-layer files need one GDAL call over a multi-layer source, or a merge with `tile-join`.
-   **Tile sizes depend on the area.** Bristol’s dense terraces made 80–115 KB zoom-14 tiles. A city of tower blocks or rural sprawl will give different numbers, so measure your own data the same way.
-   **Timings are from one laptop.** Write times depend on CPU and disk. Download sizes depend only on the data and the view, and were the same across repeated runs.
-   **The data is a snapshot.** Overture releases monthly, and places open and close. Rebuild the tiles from a new release rather than editing them.

## Frequently asked questions

### How do I create vector tiles in Python?

Install pyogrio 0.10 or later, whose wheels include GDAL 3.8+ with the PMTiles and MVT drivers. Then call `pyogrio.write_dataframe(gdf, "out.pmtiles", driver="PMTiles", layer="name", MINZOOM="12", MAXZOOM="14")`. For a folder of `{z}/{x}/{y}.pbf` files instead of one archive, use `driver="MVT"`. On the command line, `ogr2ogr -f PMTiles out.pmtiles input.gpkg -dsco MAXZOOM=14` does the same.

### What is PMTiles and why use it?

PMTiles is a single-file archive of map tiles with an index. A browser can read any tile from it with an HTTP Range request, so you host one file on static hosting or object storage instead of running a tile server or storing thousands of small files. The JavaScript library plugs into MapLibre GL JS and Leaflet. Here, four archives totalling 3.01 MB serve a whole interactive city map.

### Which zoom levels should I generate?

Stop at the lowest zoom where your features look right, and let the map overzoom beyond it. Zoom 14 (0.37 m grid in Bristol) is enough for building footprints; zoom 12 or 13 suits roads and land use. Each extra level roughly tripled the tile count here and added 2–3 MB. For low zooms, show an aggregate such as hexagons instead of every feature.

### Does Tippecanoe work on Windows?

Not natively. It builds on Linux and macOS, so on Windows run it in WSL or Docker. If you want to stay in Python on Windows, the GDAL PMTiles driver inside pyogrio works without any extra installs, as this tutorial shows.

### Why does my PMTiles map not load from python -m http.server?

Python’s built-in server ignores the `Range` header and returns the whole file with status 200, so the pmtiles library can’t read the archive directory. Use a server that supports ranges: the `serve.py` in this tutorial, `npx serve`, `npx http-server`, Nginx, or any static host.

### Can I show PMTiles on a Leaflet or OpenLayers map?

Yes. For Leaflet, the `protomaps-leaflet` library draws PMTiles vector tiles with its own styling. OpenLayers can read them through the `ol-pmtiles` package. MapLibre GL JS gives you the full style specification and smooth WebGL rendering, so it is the most common choice. For a quick Leaflet map straight from Python, see [interactive web maps with Folium](https://www.technicalgis.com/tutorials/interactive-web-maps-folium/).

## Next steps

-   Start from [part 1: query Overture Maps with DuckDB spatial and GeoParquet](https://www.technicalgis.com/tutorials/geoparquet-duckdb-overture-maps/) to change the city or add another theme, such as transportation.
-   Compare this with Leaflet maps generated from Python in [Interactive web maps with Folium](https://www.technicalgis.com/tutorials/interactive-web-maps-folium/).
-   Add the missing building heights from lidar in [Building footprints and heights from lidar in Python](https://www.technicalgis.com/tutorials/building-footprints-lidar-python/), then extrude them in MapLibre with a `fill-extrusion` layer.
-   Tile a street network from [OSMnx](https://www.technicalgis.com/tutorials/street-networks-osmnx/) as an extra source.

## Data and licences

-   **Overture Maps Foundation**, release 2026-09-23.1, via [part 1](https://www.technicalgis.com/tutorials/geoparquet-duckdb-overture-maps/). Buildings and water: ODbL 1.0, © OpenStreetMap contributors, Microsoft ML Buildings. Places: CDLA-Permissive-2.0, Apache-2.0 (Foursquare) and CC0-1.0 (AllThePlaces). See the [Overture attribution guide](https://docs.overturemaps.org/attribution/).
-   **MapLibre GL JS** 5.24.0, BSD-3-Clause, © MapLibre contributors. **pmtiles** 4.5.0 (JavaScript) and 3.8.1 (Python), BSD-3-Clause, © Protomaps LLC.
-   Code: [`pmtiles_vector_tiles.py`, `serve.py` and `screenshot_map.mjs`](https://www.technicalgis.com/code/vector-tiles-pmtiles-maplibre/), tested with Python 3.12.0, GeoPandas 1.2.0, pyogrio 0.13.0 (GDAL 3.12.4), pmtiles 3.8.1 and Playwright 1.64.1.

## Key terms

- **Vector tiles**: Map data cut into square tiles on the web-mercator grid and encoded as Mapbox Vector Tile (MVT) protobufs. The browser styles and draws them, so one set of tiles can be shown in many styles.
- **PMTiles**: A single-file archive format for map tiles from Protomaps. A header and directory point to each tile's byte range, so a client can read any tile with one HTTP Range request from a plain file on static hosting.
- **MapLibre GL JS**: An open-source (BSD-3-Clause) JavaScript library that renders vector tiles with WebGL, forked from Mapbox GL JS 1.x in 2020.
- **Overzooming**: Drawing tiles from the highest stored zoom level at higher map zooms by scaling them up. Vector geometry stays sharp, so a zoom-14 tileset can still be used at zoom 18.
- **Extent**: The integer grid inside each vector tile, 4,096 units across by default. Coordinates snap to this grid, which sets the precision at each zoom level.
- **HTTP Range request**: A request for part of a file (Range: bytes=0-16383). Servers that support it answer 206 Partial Content, which is what lets PMTiles work without a tile server.

## Data for this tutorial

-   [Overture Maps](https://www.technicalgis.com/data-sources/#src-overture-maps) · Buildings, places and water downloaded in part 1 (release 2026-09-23.1)
-   [PMTiles specification](https://github.com/protomaps/PMTiles) · Format specification and the JavaScript and Python libraries
-   [GDAL PMTiles driver](https://gdal.org/en/stable/drivers/vector/pmtiles.html) · Creation options such as MINZOOM, MAXZOOM, EXTENT and MAX\_SIZE

Licences and formats for each source are listed in the [GIS data sources catalogue](https://www.technicalgis.com/data-sources/).

[← Part 1 Query Overture Maps with DuckDB spatial and GeoParquet in Python](https://www.technicalgis.com/tutorials/geoparquet-duckdb-overture-maps/)

---

Source: https://www.technicalgis.com/tutorials/vector-tiles-pmtiles-maplibre/
