Export a folium map to HTML and share it

folium writes a Leaflet map to one HTML file with a single save call. The file opens in a browser, and a link lets anyone else open it without Python.

Folium is a Python library for making interactive maps, and m.save("map.html") exports one as a single HTML file. Open it in a browser, or paste it into a NOS document to share it as a link. Here "folium" means that Python library.

Try the smallest version first. This map is hand-written HTML of the same kind folium writes. Drag it, zoom it, and tap the dot.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Single-file Leaflet map</title>
<!-- CSS first, then the script: the same order a folium export uses -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.css">
<style>
  html, body { height: 100%; margin: 0; font-family: system-ui, sans-serif; }
  body { display: flex; flex-direction: column; }
  #info { padding: 8px 12px; font-size: 14px; background: #f4f5f7; border-bottom: 1px solid #e1e4ea; }
  #map { flex: 1; min-height: 0; background: #e6edf3; }  /* the div needs a height */
</style>
</head>
<body>
<div id="info">Drag the map, scroll or pinch to zoom, tap the dot.</div>
<div id="map"></div>

<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.js"></script>
<script>
  // folium.Map(location=(45.5236, -122.6750), zoom_start=13) writes a call like this
  const map = L.map('map', { attributionControl: false }).setView([45.5236, -122.6750], 13);

  // Shapes are drawn by Leaflet in the page, so they need no image files
  L.polygon([[45.531, -122.690], [45.531, -122.664], [45.517, -122.664], [45.517, -122.690]],
    { color: '#15803d', weight: 2, fillOpacity: 0.15 }).addTo(map);

  L.circleMarker([45.5236, -122.6750], { radius: 11, color: '#1d4ed8', fillOpacity: 0.85 })
    .addTo(map)
    .bindPopup('<b>Hello</b><br>A popup, like folium popup=')
    .openPopup();

  const info = document.getElementById('info');
  map.on('moveend', () => {
    const c = map.getCenter();
    info.textContent = 'Zoom ' + map.getZoom() + ' - centre ' + c.lat.toFixed(4) + ', ' + c.lng.toFixed(4);
  });
</script>
</body>
</html>
A Leaflet map in one file. It draws a polygon and a dot with a popup, and reports the zoom and centre as you move.

It has no street pictures on purpose. The section on tiles below explains why.

Export the map from Python

Three lines of Python make a map, and one more writes the file.

import folium

m = folium.Map(location=(45.5236, -122.6750), zoom_start=13)
folium.Marker((45.5236, -122.6750), popup="Hello").add_to(m)
m.save("map.html")

location is latitude first, then longitude. zoom_start sets the first zoom level. save writes the HTML, encoded as UTF-8, to the file name you pass.

From Python code to one HTML file to a share link.
From Python code to one HTML file to a share link.

In a notebook, a cell that ends with m shows the map but writes nothing. Only save makes a file. The Jupyter notebook guide covers sharing the notebook itself.

What is inside the saved file

The file holds your map and your points. It does not hold the libraries. It refers to them by link: Leaflet and Bootstrap from cdn.jsdelivr.net, jQuery from code.jquery.com, and the pin script from cdnjs.cloudflare.com.

The parts of a folium page, and which host each library is linked from.
The parts of a folium page, and which host each library is linked from.

So the file draws nothing offline. Folium 0.20.0 links Leaflet 1.9.3, and the demos on this page link the same version.

The page also carries a viewport meta tag and sets html and body to full width and height. Its script sits after the closing body tag, and browsers still run it.

One stylesheet on folium's list comes from netdna.bootstrapcdn.com. NOS loads scripts and styles from five hosts: cdn.jsdelivr.net, cdnjs.cloudflare.com, cdn.tailwindcss.com, code.jquery.com and unpkg.com. That one is not on the list. It supplies the small icon glyph inside the default pin.

The same map in a few lines of HTML

Leaflet does the drawing, and the folium file is a wrapper around it. The hand-written version is short:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.css">
<div id="map" style="height: 320px"></div>
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.js"></script>
<script>
  const map = L.map('map').setView([45.5236, -122.6750], 13);
  L.circleMarker([45.5236, -122.6750]).addTo(map).bindPopup('Hello');
</script>

The stylesheet comes first, and your script comes after the Leaflet script. Put your code above the Leaflet script and the console reports L is not defined.

Writing it by hand pays off when you want to change one thing the Python call makes awkward. For markers without any map library, see map markers in HTML.

Tiles are pictures

A normal folium map draws its streets from tiles: small image files fetched from a tile server to fill the map. The default tileset is OpenStreetMap. Opening a folium-style page in a test made image requests to tile.openstreetmap.org.

Tiles are image files from another site. Shapes are drawn by Leaflet inside the page.
Tiles are image files from another site. Shapes are drawn by Leaflet inside the page.

A page that blocks images from other sites, as shared NOS pages do, leaves grey where the tiles would be. Everything Leaflet draws itself still shows.

The default pin is a picture too: its stylesheet points at a file named markers-soft.png. Do not count on it showing in such a page.

Folium has a way out. Pass None as the tiles and the map is made without tiles. A CircleMarker is drawn by the page, with its radius in pixels.

m = folium.Map(location=(45.5236, -122.6750), zoom_start=13, tiles=None)
folium.CircleMarker((45.5236, -122.6750), radius=10, popup="Hello").add_to(m)

When you do keep tiles, keep the attribution. The OpenStreetMap tile policy asks you to show the licence attribution clearly on the map.

The map div needs a height

Leaflet draws into a div, and that div needs a height. Its quick start says to make sure the container has a defined height.

A folium page sets html and body to full height. Copy only the map div and script into another page and the div can collapse.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Map container height</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.css">
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #fff; color: #1d2330; }
  h2 { font-size: 14px; margin: 0 0 6px; }
  .box { border: 2px solid; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
  .bad { border-color: #ea580c; }
  .good { border-color: #16a34a; }
  .box h2 { padding: 8px 10px; margin: 0; background: #f8fafc; }
  .map { background: #e6edf3; }
  #bad { }               /* no height: the div collapses to 0 */
  #good { height: 110px; }
  button { font: inherit; padding: 6px 12px; border-radius: 8px; border: 1px solid #94a3b8; background: #fff; cursor: pointer; }
  #note { font-size: 13px; margin-left: 8px; }
</style>
</head>
<body>
<div class="box bad">
  <h2>Without a height: <span id="badH"></span></h2>
  <div id="bad" class="map"></div>
</div>
<div class="box good">
  <h2>With height: 110px: <span id="goodH"></span></h2>
  <div id="good" class="map"></div>
</div>
<button id="fix" type="button">Give the first map a height</button><span id="note"></span>

<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.js"></script>
<script>
  function make(id) {
    const m = L.map(id, { attributionControl: false }).setView([45.5236, -122.675], 13);
    L.circleMarker([45.5236, -122.675], { radius: 10 }).addTo(m);
    return m;
  }
  const badMap = make('bad');    // no error is raised, the map is just 0 px tall
  const goodMap = make('good');

  function report() {
    document.getElementById('badH').textContent = document.getElementById('bad').offsetHeight + ' px tall';
    document.getElementById('goodH').textContent = document.getElementById('good').offsetHeight + ' px tall';
  }
  report();

  document.getElementById('fix').addEventListener('click', () => {
    document.getElementById('bad').style.height = '110px';
    badMap.invalidateSize();  // tell Leaflet the container size changed
    report();
    document.getElementById('note').textContent = 'Fixed.';
  });
</script>
</body>
</html>
Two maps. The first div has no height and is 0 px tall. The second has a height. The button fixes the first one.

No error appears when a map is 0 px tall. It is simply invisible. If you change the size after the map was drawn, call invalidateSize() so Leaflet checks the container again.

A finished page: points from a list

Real maps come from data. In this page each row of a list becomes a circle marker with a popup, and the map zooms to fit them all.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Places map</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.css">
<style>
  body { margin: 0; padding: 10px; font-family: system-ui, sans-serif; color: #1d2330; background: #fff; }
  .filters { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 8px; font-size: 14px; }
  .filters label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
  .dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
  #map { height: 300px; border-radius: 10px; background: #e6edf3; border: 1px solid #cbd5e1; }
  #list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; padding: 0; list-style: none; }
  #list button { font: inherit; font-size: 13px; padding: 5px 10px; border-radius: 999px; border: 1px solid #cbd5e1; background: #fff; cursor: pointer; }
  #list button:hover { background: #f1f5f9; }
  #count { font-size: 13px; color: #475569; margin-top: 6px; }
</style>
</head>
<body>
<div class="filters" id="filters"></div>
<div id="map"></div>
<ul id="list"></ul>
<div id="count"></div>

<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.3/dist/leaflet.js"></script>
<script>
  // Sample data: one row per place, like a pandas DataFrame
  const places = [
    { name: 'Corner Cafe',   type: 'Cafe', ll: [45.5231, -122.6765] },
    { name: 'Bridge Coffee', type: 'Cafe', ll: [45.5189, -122.6682] },
    { name: 'Late Bean',     type: 'Cafe', ll: [45.5274, -122.6841] },
    { name: 'Elm Park',      type: 'Park', ll: [45.5261, -122.6702] },
    { name: 'River Green',   type: 'Park', ll: [45.5205, -122.6805] },
    { name: 'Book Nook',     type: 'Shop', ll: [45.5242, -122.6893] },
    { name: 'Bike Barn',     type: 'Shop', ll: [45.5178, -122.6738] },
    { name: 'Toy Box',       type: 'Shop', ll: [45.5296, -122.6746] }
  ];
  const colors = { Cafe: '#c2410c', Park: '#15803d', Shop: '#1d4ed8' };

  const map = L.map('map', { attributionControl: false });

  // Background context drawn as shapes: a river and two green areas
  L.polyline([[45.5330, -122.6900], [45.5240, -122.6780], [45.5190, -122.6700], [45.5150, -122.6640]],
    { color: '#93c5fd', weight: 10, opacity: 0.8 }).addTo(map);
  L.polygon([[45.5275, -122.6730], [45.5275, -122.6675], [45.5247, -122.6675], [45.5247, -122.6730]],
    { stroke: false, fillColor: '#86efac', fillOpacity: 0.5 }).addTo(map);
  L.polygon([[45.5222, -122.6830], [45.5222, -122.6780], [45.5188, -122.6780], [45.5188, -122.6830]],
    { stroke: false, fillColor: '#86efac', fillOpacity: 0.5 }).addTo(map);

  const list = document.getElementById('list');
  const box = document.getElementById('filters');
  const count = document.getElementById('count');
  const shown = { Cafe: true, Park: true, Shop: true };

  places.forEach((p) => {
    p.marker = L.circleMarker(p.ll, {
      radius: 10, color: '#fff', weight: 2, fillColor: colors[p.type], fillOpacity: 0.95
    }).bindPopup('<b>' + p.name + '</b><br>' + p.type);
    const li = document.createElement('li');
    const b = document.createElement('button');
    b.type = 'button';
    b.textContent = p.name;
    b.addEventListener('click', () => {
      map.flyTo(p.ll, 15, { duration: 0.6 });
      p.marker.openPopup();
    });
    li.appendChild(b);
    list.appendChild(li);
    p.li = li;
  });

  Object.keys(colors).forEach((type) => {
    const label = document.createElement('label');
    const cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.checked = true;
    cb.addEventListener('change', () => { shown[type] = cb.checked; refresh(true); });
    const dot = document.createElement('span');
    dot.className = 'dot';
    dot.style.background = colors[type];
    label.append(cb, dot, type);
    box.appendChild(label);
  });

  function refresh(refit) {
    const visible = [];
    places.forEach((p) => {
      const on = shown[p.type];
      p.li.hidden = !on;
      if (on) { p.marker.addTo(map); visible.push(p.marker); } else { p.marker.remove(); }
    });
    count.textContent = visible.length + ' of ' + places.length + ' places shown';
    if (refit && visible.length) map.fitBounds(L.featureGroup(visible).getBounds(), { padding: [30, 30] });
  }

  map.fitBounds(L.latLngBounds(places.map((p) => p.ll)), { padding: [30, 30] });
  refresh(false);
</script>
</body>
</html>
Eight sample places. Untick a type to hide it, or press a name to fly to its popup.
  • Data: one object per place, like the rows you would loop over in Python.
  • Markers: a circleMarker per row, coloured by type, with bindPopup.
  • Fit: fitBounds zooms to the places that are shown.
  • Controls: the checkboxes and name buttons are plain HTML added around the map.

The Python version of the loop is short:

for name, lat, lng in places:
    folium.CircleMarker((lat, lng), radius=9, popup=name).add_to(m)

When it does not work

What you see Cause Fix
Blank page, nothing draws Libraries are linked from CDNs and the device is offline Open it with a connection
Grey background, markers show Tile images are blocked tiles=None, shapes for context
Pin or its icon is missing The default pin is an image plus an icon font Use CircleMarker
Map area is empty The map div has no height Give the div a height
Map opens in the wrong place Longitude and latitude swapped Latitude first
Notebook shows a map, no file Display is not saving m.save("map.html")
L is not defined Your script runs before Leaflet loads Put it after the Leaflet script
Phone shows code or nothing An HTML attachment may not open Send a link

An exported map is made to be dragged and zoomed, and a screenshot cannot do either. Sent as an attachment, the .html file may open as plain code, or not at all, on a phone. Opening an HTML file on a phone explains why.

For another export that goes the same way, see sharing a Plotly export.

To send the working map, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so people can drag and zoom the map themselves. Anyone with the link can open it without an account. If you change the code later, the same link shows the new version.

Questions people ask

Does folium save a single HTML file?

Yes. Calling save on a map writes the page to the file name you give. The file holds your map and points, and it refers to Leaflet, jQuery and Bootstrap by link instead of copying them in, so it needs an internet connection to draw the map.

Do I need Python to open the saved file?

No. The file is ordinary HTML with JavaScript. Any browser that can reach the linked libraries draws the map. Python is only needed to make the file, or to regenerate it after the data changes.

Why is my shared folium map grey?

The street picture is made of tile images fetched from a tile server. A page that blocks images from other sites cannot show them, so the background stays grey. Markers, circles and polygons are drawn by the page and still appear. Use tiles=None and draw your own context.

Is folium free to use?

The package page on PyPI lists folium under the MIT licence. The default OpenStreetMap tiles have their own usage policy: show the attribution on the map, and expect best-effort availability with no guarantee.

What is the difference between showing a map in a notebook and saving it?

In a notebook, a cell that ends with the map object displays it through _repr_html_, and no file is written. Calling m.save("map.html") is what creates a file you can open, send or paste into a document.

Keep reading