Part of the Static vs Dynamic Export Methods guide.
Operative rule: a truly self-contained export has zero external runtime dependencies — every script, stylesheet, data payload, and tile it needs is embedded in the .html file itself, so it renders identically with the network unplugged.
How the Folium Export Works
folium.Map.save() renders a Jinja2 template into a complete HTML document. That document is not empty of dependencies: by default it contains <script> and <link> tags pointing at CDN-hosted builds of Leaflet, jQuery, Bootstrap, and Font Awesome, and each TileLayer becomes a Leaflet layer whose URL template points at a remote tile server. Open the file with no network and you get an unstyled shell, missing controls, and a grid of blank tiles. The map object is correct; its dependencies are simply somewhere else.
Making the artifact self-contained means resolving three distinct classes of dependency at export time rather than at open time. Library assets (Leaflet JS/CSS and the control libraries) are fetched once during the build and rewritten from remote <script src> / <link href> tags into inline <script> and <style> blocks. Vector data is embedded by passing parsed GeoJSON objects — not URLs — to folium.GeoJson, which serializes them into the page as JavaScript literals. Basemap tiles are the hard case: they are inherently many files, so you either omit the basemap, point the TileLayer at a locally bundled tile directory, or base64-embed a small pre-rendered tile set. This is the same trade-off surface as exporting PyDeck visualizations to standalone HTML, and it sits at the heart of the static vs dynamic export methods decision.
Production-Ready Implementation
The routine below builds a Folium map with embedded GeoJSON, saves it, and then post-processes the HTML to inline the Leaflet library assets. It fetches each CDN asset once, substitutes the remote reference with an inline block, and writes a single file. The embed_geojson step passes a parsed object (never a URL) so the vector data serializes into the page. Basemap handling is explicit: pass tiles=None for a data-only map, or a local tile template for a bundled basemap.
from __future__ import annotations
import re
from pathlib import Path
from urllib.request import urlopen
import folium
def fetch(url: str) -> str:
"""Fetch a text asset once, at build time, for inlining."""
with urlopen(url, timeout=30) as resp: # build-time only, not runtime
return resp.read().decode("utf-8")
def build_map(geojson_obj: dict, center: list[float]) -> folium.Map:
"""Assemble a Folium map with embedded (not fetched) GeoJSON."""
# tiles=None yields a data-only canvas with no remote basemap.
m = folium.Map(location=center, zoom_start=11, tiles=None)
# Passing the parsed dict (not a URL) inlines the data as a JS literal.
folium.GeoJson(
geojson_obj,
name="regions",
style_function=lambda _f: {"color": "#2b6", "weight": 1.5},
).add_to(m)
folium.LayerControl().add_to(m)
return m
def inline_cdn_assets(html: str) -> str:
"""Rewrite external <link>/<script> CDN tags into inline blocks."""
# Inline stylesheets: <link rel="stylesheet" href="https://.../x.css">
def _css(match: re.Match[str]) -> str:
href = match.group(1)
return f"<style>/* inlined {href} */\n{fetch(href)}</style>"
html = re.sub(
r'<link[^>]+href="(https://[^"]+\.css)"[^>]*>',
_css,
html,
)
# Inline scripts: <script src="https://.../x.js"></script>
def _js(match: re.Match[str]) -> str:
src = match.group(1)
return f"<script>/* inlined {src} */\n{fetch(src)}</script>"
html = re.sub(
r'<script[^>]+src="(https://[^"]+\.js)"[^>]*>\s*</script>',
_js,
html,
)
return html
def export_selfcontained(
geojson_obj: dict,
center: list[float],
out_path: Path,
) -> Path:
"""Save a Folium map and inline every external asset it references."""
m = build_map(geojson_obj, center)
# m.save() writes CDN-linked HTML; capture it as a string to rewrite.
raw_html: str = m.get_root().render()
embedded_html = inline_cdn_assets(raw_html)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(embedded_html, encoding="utf-8")
return out_path.resolve()
if __name__ == "__main__":
sample = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"name": "Zone A"},
"geometry": {
"type": "Polygon",
"coordinates": [[
[-74.02, 40.70], [-73.97, 40.70],
[-73.97, 40.74], [-74.02, 40.74],
[-74.02, 40.70],
]],
},
}
],
}
path = export_selfcontained(sample, [40.72, -74.0], Path("dist/map.html"))
print(f"Wrote self-contained map: {path}")
Alternative Variants
One-liner save plus a bundled local basemap
When you accept a small local tile set rather than a base64 payload, point the TileLayer at a relative path that ships alongside the HTML. This keeps the file readable while still avoiding any remote server. The directory of tiles travels with the .html as a sibling folder.
import folium
m = folium.Map(location=[40.72, -74.0], zoom_start=11, tiles=None)
# Relative template served from the same directory as the HTML output.
folium.TileLayer(
tiles="./tiles/{z}/{x}/{y}.png",
attr="Bundled offline tiles",
name="offline-basemap",
).add_to(m)
m.save("dist/map.html") # ships next to dist/tiles/
Embedding decision reference
| Dependency | Default in m.save() | Self-contained approach |
|---|---|---|
| Leaflet CSS | <link> to CDN |
Fetch at build, inline as <style> |
| Leaflet JS | <script src> to CDN |
Fetch at build, inline as <script> |
| Vector data | URL passed to GeoJson |
Pass parsed dict, serialized as literal |
| Raster imagery | Remote tile server | Bundle local tiles or base64 a small set |
| Marker icons | Font Awesome CDN | Inline the icon font or use SVG markers |
For a fully embedded basemap on a tiny extent, read each PNG tile, base64-encode it, and hand Leaflet a data: URL template — practical only for a handful of tiles because base64 inflates payload by roughly a third. Any residual https:// reference in the output negates the self-contained guarantee and must be resolved before the file is treated as offline-ready. Once the artifact is sealed, the tightened dependency surface also simplifies setting Content-Security-Policy headers for embedded Folium maps, because there are no external origins left to allowlist.
Verification Steps
Prove the artifact is genuinely self-contained before shipping:
- No remote references remain — grep the output for
https://andhttp://; a self-contained file should return no matches inside<script src>,<link href>, or tile URL templates. - Renders with the network unplugged — disable networking (or use DevTools offline mode) and open the file; controls, styling, and vector overlays must all appear.
- GeoJSON is a literal, not a fetch — search the HTML for your feature property names; they should appear inline, and the Network panel should show zero data requests.
- File size is sane — inlining Leaflet plus data typically lands under 1 MB; a multi-megabyte file usually means base64 tiles crept in and should be reviewed.
- Opens as a top-level page — double-click the file directly; it must render from the
file://scheme without a server, confirming no absolute-path assumptions.
Common Errors & Fixes
The map is blank when opened offline
At least one dependency is still remote. Most often the basemap TileLayer still points at a remote server, or a control library slipped through the inliner. Grep for https://, then either inline the missed asset or set tiles=None and supply a bundled basemap. Reopen with the network disabled to confirm the fix.
Controls appear unstyled or icons are missing boxes
The Leaflet or Font Awesome CSS was not inlined, so control glyphs fall back to empty boxes. Extend the inliner to match every <link rel="stylesheet">, and for marker icons either inline the icon font as a data: URL inside the CSS or switch to folium.DivIcon with inline SVG, which carries no font dependency at all.
File is enormous and slow to open
Base64-embedded tiles are inflating the payload; each encoded tile is about a third larger than its binary form, and a full zoom pyramid explodes quickly. Restrict embedded tiles to a single small extent and zoom range, or move the basemap to a bundled sibling tiles/ directory referenced by a relative path so the imagery stays binary on disk.
URLError or timeout during the build
The inliner fetches CDN assets at build time, so a blocked or offline build environment fails there rather than at open time. Vendor the Leaflet JS/CSS into the repository and read them from disk in fetch() instead of over the network, which also makes the build reproducible in air-gapped CI.
Related
- Static vs Dynamic Export Methods — parent guide weighing pre-rendered bundles against live data endpoints
- Exporting PyDeck Visualizations to Standalone HTML — the WebGL counterpart to Folium’s Leaflet export, with the same inlining trade-offs
- Serving PyDeck Output Through 11ty Data Pipelines — templating exported map artifacts into a static site at build time
- Setting Content-Security-Policy Headers for Embedded Folium Maps — locking down a map whose dependency surface you have already minimized