Web Mapping & Interactive Visualization with Python
Processing and analysis are only half of a geospatial project — at some point the results have to reach a browser. This domain covers the visualization layer of the Python stack: turning a validated GeoDataFrame into an interactive map a stakeholder can pan, zoom, and click. It connects the analytical pillars — the libraries covered in Mastering Core Geospatial Python Libraries and the pipelines in Geospatial Data Ingestion & Processing Workflows — to the rendering technologies that run client-side. Three child topics structure the work: rapid prototyping with Interactive Maps with Folium, production vector rendering with MapLibre GL Vector Web Maps, and scalable delivery through Vector Tile Pipelines with PMTiles.
Ecosystem Architecture & Dependency Management
The Python web-mapping ecosystem splits cleanly into two responsibilities: generating map artifacts in Python and rendering them in JavaScript. Folium wraps the Leaflet.js library and emits self-contained HTML — ideal for notebooks and quick shares. For larger datasets, Python's role shifts to producing tiles (MBTiles, PMTiles) or GeoJSON that a dedicated client renderer such as MapLibre GL JS consumes. Keeping these layers separate is what lets a 50-row demo and a 5-million-feature production map share the same upstream analysis code.
Install the rendering-adjacent toolchain in an isolated environment. Pin versions, because Folium's Leaflet bundle and the tile-generation libraries evolve independently:
# environment.yml (conda-forge channel)
# name: webmap
# dependencies:
# - python=3.11
# - geopandas=0.14.*
# - folium=0.16.*
# - pmtiles=3.2.*
# - gdal=3.8.* # provides ogr2ogr for tile generation
# - pip
# - pip: ["pyogrio>=0.7"]
import importlib
WEBMAP_PACKAGES = ["geopandas", "folium", "pmtiles", "shapely", "pyproj"]
def verify_webmap_stack() -> None:
missing = [pkg for pkg in WEBMAP_PACKAGES if not importlib.util.find_spec(pkg)]
if missing:
raise RuntimeError(f"Missing web-mapping dependencies: {', '.join(missing)}")
print("Web mapping stack ready.")
verify_webmap_stack()
The tippecanoe tile generator (used for large vector tile builds) is a C++ binary, not a Python package — install it via your system package manager or a container layer and call it as a subprocess. Treat it like GDAL: a native dependency the Python code orchestrates rather than imports.
Core Concepts & Data Model
Every web map is a stack of layers drawn over a base map, positioned by a viewport (center plus zoom). The data you supply is either raster (pre-rendered image tiles) or vector (GeoJSON or vector tiles the client styles on the fly). The single most important rule: web clients expect geographic coordinates. Leaflet and MapLibre consume EPSG:4326 longitude/latitude and internally project to EPSG:3857 (Web Mercator) for tile placement. Your analysis should happen in a projected CRS — see Coordinate Systems with PyProj — and only convert to 4326 at the moment of export.
import folium
import geopandas as gpd
# floodplain_boundary was analysed in a metric UTM CRS upstream
floodplain_boundary = gpd.read_file("floodplain_utm.gpkg")
# Reproject to WGS84 ONLY for web delivery
floodplain_wgs84 = floodplain_boundary.to_crs(epsg=4326)
centroid = floodplain_wgs84.geometry.union_all().centroid
fmap = folium.Map(location=[centroid.y, centroid.x], zoom_start=11, tiles="CartoDB positron")
folium.GeoJson(floodplain_wgs84, name="Floodplain").add_to(fmap)
fmap.save("floodplain_map.html")
Note the [centroid.y, centroid.x] ordering — Leaflet and Folium take [lat, lon], the opposite of the (x, y) ordering used throughout the analytical libraries. This axis flip is the most common first bug in web mapping code.
Key Operations & Vectorized Workflows
Four operations cover the majority of practical web-mapping work:
1. Render a vector overlay. Convert a GeoDataFrame to GeoJSON and add it as a styled layer. For interactive choropleths, see Folium Choropleth from a GeoDataFrame.
2. Cluster dense point sets. Thousands of raw markers freeze the browser; cluster them server-side or with Leaflet's clustering plugin, covered in Clustering Map Markers with Folium.
3. Stream GeoJSON to a vector renderer. MapLibre GL JS styles raw GeoJSON sources — see Serving GeoJSON to MapLibre GL JS.
4. Build tiles for scale. Past ~5 MB of GeoJSON, switch to vector tiles. Generating PMTiles from GeoParquet shows the cloud-native path.
import geopandas as gpd
sensors = gpd.read_file("air_quality_sensors.gpkg").to_crs(epsg=4326)
# Trim precision before serialization: 6 decimals ≈ 0.11 m, plenty for web
sensors["geometry"] = sensors.geometry.set_precision(1e-6)
# Drop heavy attribute columns the client never displays
web_cols = ["sensor_id", "pm25", "geometry"]
sensors[web_cols].to_file("sensors_web.geojson", driver="GeoJSON")
Precision trimming and column pruning routinely cut payload size by half — the cheapest performance win in web mapping.
CRS / Projection Considerations
The web-mapping CRS contract is narrow but unforgiving. Tiles are addressed in the Web Mercator grid (EPSG:3857), but the data you hand to a client should be tagged EPSG:4326; the renderer performs the Mercator projection. Three failure modes recur:
- Forgetting to reproject. A
GeoDataFrameleft in UTM places features in the Gulf of Guinea (coordinates near 0,0 in degrees). Always call.to_crs(epsg=4326)before export. - Doing metric math after reprojecting. Areas and distances computed in EPSG:4326 degrees are meaningless. Buffer and measure upstream in a projected CRS, then convert the result.
- Web Mercator distortion. EPSG:3857 stretches area badly toward the poles. Never use it for choropleth normalization (e.g., density per km²); compute densities in an equal-area projection first.
import geopandas as gpd
parcels = gpd.read_file("parcels.gpkg") # EPSG:32633 (UTM 33N)
# Compute density in the projected CRS where area is in metres²
parcels["density"] = parcels["population"] / (parcels.geometry.area / 1e6)
# THEN reproject the finished layer for the browser
parcels_web = parcels.to_crs(epsg=4326)
assert parcels_web.crs.to_epsg() == 4326
Production Patterns & Performance
The dividing line in production web mapping is payload size. Below a few megabytes, inline GeoJSON is simplest. Above it, you need tiles so the client only downloads the viewport at the current zoom. The cloud-native answer is PMTiles — a single-file tile archive served over HTTP range requests with no tile server, conceptually identical to the Cloud-Native Geospatial Formats used upstream.
- Generate vector tiles with
tippecanoeand pack them into PMTiles; host on any static bucket. - Simplify geometry per zoom level — full-resolution coastlines at zoom 3 waste bandwidth.
- Serve compressed (
Content-Encoding: gzip) GeoJSON; it shrinks 5–10×. - Cache base tiles; only your data layer should change between requests.
- For raster overlays, derive Cloud Optimized GeoTIFFs from Raster Data Handling with Rasterio and let the client fetch windows.
Common Mistakes
- Passing
[lon, lat]to Folium. Leaflet expects[lat, lon]; reversed coordinates drop your map in the wrong hemisphere. - Shipping a 40 MB GeoJSON to the browser. Past ~5 MB, switch to vector tiles or the page will hang on load.
- Leaving data in a projected CRS. Web renderers need EPSG:4326; UTM coordinates render off-globe.
- Normalizing choropleths in Web Mercator. Area distortion corrupts per-area metrics; compute in an equal-area CRS first.
- Embedding full-precision coordinates. 15 decimal places is nanometre precision no screen can show; trim to 6.
- Rendering thousands of un-clustered markers. The DOM chokes; cluster or use a canvas/WebGL renderer.
- Forgetting
name=on layers. Without it, the layer control can't toggle overlays.
Frequently Asked Questions
Should I use Folium or MapLibre GL JS? Use Folium for notebooks, internal dashboards, and anything you want as a one-file HTML artifact. Use MapLibre GL JS when you need smooth vector rendering, data-driven styling, or datasets large enough to require vector tiles. Folium renders raster-style Leaflet maps; MapLibre is a WebGL vector renderer.
At what size should I stop using GeoJSON and switch to tiles? A practical threshold is ~5 MB of uncompressed GeoJSON or a few tens of thousands of features. Beyond that, the browser stalls parsing and rendering, and vector tiles (PMTiles/MBTiles) become worth the build step.
Do I need a tile server? Not anymore. PMTiles serves an entire tileset from a single static file using HTTP range requests, so a plain object store or CDN replaces a running tile server for most read-only workloads.
Why does my map show the data in the wrong place?
Almost always a CRS or axis-order issue: either the data wasn't reprojected to EPSG:4326, or coordinates were passed as [lon, lat] where Leaflet expects [lat, lon].
Can I put a raster analysis result on a web map? Yes — export it as a Cloud Optimized GeoTIFF and either pre-render image tiles or let a client like MapLibre/Leaflet fetch it. Reproject to EPSG:3857 or supply it as a 4326 source depending on the renderer.