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

Turn Overture Maps GeoParquet into PMTiles vector tiles with the GDAL that ships in pyogrio, no Tippecanoe needed, measure the zoom-level and size trade-offs, and serve an interactive MapLibre GL JS map of Bristol from static hosting.

Level
Intermediate
Tested with
Python 3.12
Source code
Download scripts

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 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, 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

Terminal window
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
import pyogrio
print(pyogrio.__gdal_version_string__)
drivers = pyogrio.list_drivers()
print(drivers.get("PMTiles"), drivers.get("MVT"))
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

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)
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:

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:

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:

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
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
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:

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
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:

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}")
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:

<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):

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, a ThreadingHTTPServer subclass of about 50 lines that adds byte ranges:

Terminal window
python serve.py # serving output/web on http://localhost:8000/
Terminal window
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 ↗

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:

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
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
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
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.

Next steps

Data and licences

  • Overture Maps Foundation, release 2026-09-23.1, via part 1. 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.
  • 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, 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.
Map of central Bristol with 144,965 Overture building footprints coloured by subtype, most of them residential, and the River Avon in blue

Tutorial Python Part 1 of 2

Query Overture Maps with DuckDB spatial and GeoParquet in Python

Pull Bristol's 144,965 buildings and 20,727 places straight from the Overture Maps GeoParquet release with DuckDB, run spatial SQL joins and H3 aggregation, load the result into GeoPandas, write GeoParquet 1.1, and time it against Shapefile, GeoPackage and FlatGeobuf.

Intermediate 13 min read

Six 128 by 128 pixel Sentinel-2 image chips of Nottingham, each shown above the same chip with its label mask overlaid: a city centre with red buildings and grey streets, a suburb, a forest, farmland, flooded gravel pits in blue and an industrial estate.

Tutorial Python Part 1 of 3

Training data for deep learning on satellite imagery: Sentinel-2 image chips in Python

Build a labelled deep learning dataset from free data: stream a Sentinel-2 L2A composite from the Planetary Computer, rasterise Overture building footprints over ESA WorldCover, cut georeferenced image chips, split them by spatial block so the test set does not leak, check the class balance and load it all as a TorchGeo dataset.

Intermediate 13 min read

Map of the Holderness coast from Skipsea to Withernsea in greyscale Sentinel-2, with each 100 m transect coloured by its shoreline change rate from 2017 to 2026: mostly red (retreat), with blue (advance) patches north of Hornsea, at Mappleton and near Grimston.

Tutorial Python

Coastal erosion and shoreline change from Sentinel-2 in Python (Holderness, 2017–2026)

Measure shoreline change on one of Europe's fastest-eroding coasts with free Sentinel-2 imagery and Python: MNDWI and Otsu thresholding, sub-pixel shorelines with find_contours, shore-normal transects, a tide correction from open tide-gauge data, end-point and linear regression rates, and an honest comparison with East Riding of Yorkshire Council's cliff monitoring.

Advanced 21 min read