Mapping Scatter Plots on Folium Maps with folium.CircleMarker()

(D) Data Visualization
geospatial data
HTML
library - pandas
library - base64
library - folium
library - IPython.display
Maps using the reverse-teardrop shaped marker in folium can quickly become congested and difficult to navigate. This tutorial shows how to use folium.CircleMarker() object to generate simple scatter plot like maps, complete with hover over and pop up text. Complete code is available in the GitHub repository.
Author

Mai Tanaka

Published

August 31, 2026

Mapping scatter plot like markers with hover over and pop-up text with folium’s CircleMarker method.

Mapping scatter plot like markers with hover over and pop-up text with folium’s CircleMarker method.

1 Introduction

Drawing beautiful maps with Python is easy with the folium library. Locations with known latitude and longitude coordinates can be marked with the reverse-teardrop pointer, with the library taking care of the base map.

Figure 1: Screenshot of a typical folium map with a reverse-teardrop shaped folium.Marker() indicating the location of Tokyo.

The folium.Marker() marker, aesthetically similar to markers using in popular map apps, is nice when you have a couple locations to mark. However, when marking close to a hundred locations on the folium map, the reverse-teardrop shaped marker can overcrowd the map making it difficult to decipher. Once you’re nearing a thousand markers, chances are, your folium map will not render due to lack of memory.

One possible solution is to use the folium.CircleMarker() object, which is much lighter than the folium.Marker() object.

This blog post shows you how to use the folium.CircleMarker() object to generate simple scatter plot-like markers on folium maps. We’ll cover how to change the marker’s color and opacity, add mouse-over text which appears when the cursor hovers over the marker, and install pop up text that appears when the user clicks on the marker. Map legends for circle markers will be covered in another blog post.

1.1 What You’ll Learn in This Tutorial

By the end of this tutorial, you’ll learn how to:

  • Mark locations on a map using folium.CircleMarker() and modify the marker’s
    • fill and stroke color
    • opacity
  • Add mouse-over text using HTML that appears when the cursor hovers over the object
  • Generate pop up text using HTML that appears when the user clicks the object

If you prefer to skip the explanations and jump straight to the implementation, you can download the code from my GitHub repository.

Here is the list of things you’ll need to run the code.

1.2 Prerequisites

  • A copy of either the folium-map-scatter-plot.ipynb Jupyter notebook or folium-map-scatter-plot.py Python script from my GitHub repository
  • data/ subfolder
  • Python libraries
    • pandas
    • folium
    • base64
    • IPython.display

1.3 Jargon

Pop up: The small graphical user interface that appears when the user clicks on a screen element.

Tooltip: The small graphical user interface that appears when the cursor hovers over a screen element. Depending on the coding language used, it may be called hover over text or mouse-over text.

2 Rendering folium Maps in Your IDE

Before drawing folium maps, I’d like to address a relatively common issue seen in (at least) VS Code. Namely, the maps may not render in the Jupyter notebook cell due to “trust issues” between the file and the folium library (Figure 2). Depending on the IDE you use, you may also run into this issue.

Figure 2: Maps not rendering in the Jupyter notebook due to trust issues between folium and VS Code.

Many of the solutions I’ve tried seem to be inconsistent. The issue resurfaces after a few runs.

For me, the one solution that worked consistently was to make a function that embeds the folium map into an IFrame. To display the map, you simply call the function within the Jupyter notebook.

The only downside to this solution is that we’ll need the base64 and IPython.display libraries for the function to run smoothly.

# Libraries needed to embed the folium map into an IFrame using base64 encoding
import base64
from IPython.display import IFrame, display

Please note that I did not write this function. It comes straight from the GitHub discussions.

def show_folium_safe(m, height=500):
    """
    Displays a Folium map in a safe IFrame using Base64 encoding.
    This avoids "Trusted" errors, file path issues, and CSS leakage.
    Source: https://github.com/microsoft/vscode-jupyter/issues/17224#issuecomment-3679624559
    """
    # 1. Get the raw HTML string of the map
    html_content = m.get_root().render()
    
    # 2. Encode the HTML to base64
    # This allows us to put the entire map "inside" the URL string
    encoded = base64.b64encode(html_content.encode('utf-8')).decode('utf-8')
    
    # 3. Create a Data URI
    data_uri = f"data:text/html;charset=utf-8;base64,{encoded}"
    
    # 4. Display the IFrame
    # We use width='100%' to fill the cell width, but the CSS is trapped inside
    display(IFrame(src=data_uri, width="100%", height=height))

So now, instead of calling m to display the map, we call show_folium_safe(m) instead.

Now, back to drawing scatter plots on maps.

3 Drawing folium Maps

The most basic of folium maps can be drawn by creating a folium.Map() object. Let’s import the folium library and draw out a basic map.

# Import folium library
import folium
# Make a generic folium map
m = folium.Map()

# Show the map
# m # If IDE does not render map use line below
show_folium_safe(m)
Figure 3: Calling folium.Map() with no parameters creates a generic map of the world.

Rarely do we want such a vague and generic map. To make the map more informative, I suggest we start with three parameters when calling folium.Map().

  • location
  • tiles
  • zoom_start

The location parameter specifies the latitude and longitude of the center of the map. The tiles parameter dictates the map style and the basic information to be drawn on the map. The starting zoom level can be assigned using the zoom_start parameter.

The default map tile is openstreetmap. Here’s what it looks like centered on the Sendai region in Japan with a starting zoom value of 10:

# Generate a folium map
sendai_lat_long = (38.268, 140.869)
m = folium.Map(location=sendai_lat_long, 
               tiles='openstreetmap', 
               zoom_start=10,
               min_zoom=9,
               max_zoom=11,
               prefer_canvas=True)

# Show the map
show_folium_safe(m)
Figure 4: Map of Sendai using the openstreetmap tile at starting zoom level of 10.

You can see the major roads, lakes and rivers, cities and towns and prefectural boundaries.

Here’s the same map with a lower starting zoom level of 5:

# Generate a folium map
m = folium.Map(location=sendai_lat_long, 
               tiles='openstreetmap', 
               zoom_start=5,
               min_zoom=4,
               max_zoom=6,
               prefer_canvas=True)

# Show the map
show_folium_safe(m)
Figure 5: Map of Sendai using the openstreetmap tile at starting zoom level of 5.

A lower zoom level means we have zoomed out. As such, Figure 5 is moreso a map of Japan, than it is a map of the Sendai region in Japan.

You may have noticed that the map zooms in and out quite a bit in Figure 3. If this feature is too much, you can specify min_zoom and max_zoom parameters, as I have done in Figure 4 and Figure 5. This prevents the map from zooming out or in past a certain limit. I’ve also included the prefer_canvas parameter, which is a backend setting that helps with the plotting of a large number of circle markers, which we will be doing in later sections.

For the tiles option, folium can use any map tile available from the xyzservices package, a lightweight library of raster basemap tiles. You can preview map styles available from xyzservices here.

Note that some map tiles require separate registration. Additionally, depending on your intended use case for your maps, you may need to be well versed in each of the map data licenses. I’ll cover a couple of the map tile licenses below.

3.1 Map Tiles and Their Licenses

When using Python libraries heavy with backend data, it is important to know whether you are allowed to copy, modify, share or even commercialize from the data or code. Here, I list the licenses involved in some of the map tiles that power folium maps. If you use a map tile not in this list, be sure to check their license.

3.2 folium and leaflet Licenses

First of all, folium and its underlying leaflet library are the software associated with creating the interactive maps. As of August 2026, folium has a permissive license which allows for great freedom.

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

Similarly, leaftlet is licensed under the BSD-2 clause license, allowing people to use their code even for commercial purposes.

3.3 Map Data Licenses

While the folium library visualizes the map, it does not own the data used to draw the maps. The license for usage of the maps depends on the entity that owns the map data, and different entities own the different folium map tiles. Additionally, some map tiles require registration, which is a whole different process of its own.

Here, I list some of the easily accessible map tile options which can be previewed here, draw out what they look like, and provide a brief explanation of their license.

3.3.1 OpenStreetMap

The OpenStreetMap data’s license is, as the name implies, open.

You are free to copy, distribute, transmit and adapt our data, as long as you credit OpenStreetMap and its contributors.

This includes commercial use.

However, you are required to credit OpenStreetMap when using their data. Luckily, when you draw the maps using folium, they take care of this by placing a small attrition to OpenStreetMap in the lower right corner of the interactive maps.

Figure 6 shows an example of the OpenStreetMap map tile. Take note of the credits in the lower right corner.

# Generate a folium map
m = folium.Map(location=sendai_lat_long, 
               tiles='openstreetmap', 
               zoom_start=7)

# Show the map
show_folium_safe(m)
Figure 6: Map of Sendai using the openstreetmap tile at starting zoom level of 7.

One other thing to keep in mind is if you decide to build upon their data. Your end result must also be distributed under the same open license.

If you alter or build upon our data, you may distribute the result only under the same license.

3.3.2 CartoDB

On the other hand, the three basic CartoDB map options ('cartodb positron', 'cartodb darkmatter' and 'cartodb voyager') have some restrictions on their usage. For one, you will need an API key to use their map tiles. Additionally, if you are thinking of commercializing based on the CartoDB maps, you will need to become a CARTO enterprise customer.

Similar to OpenStreetMap, the CartoDB maps also require attrition, which is taken care of with folium in the lower right corner of the maps.

Figure 7 shows what the cartodb positron map looks like:

# Generate a folium map
m = folium.Map(location=sendai_lat_long, 
               tiles='cartodb positron', 
               zoom_start=7)

# Show the map
show_folium_safe(m)
Figure 7: Map of Sendai using the cartodb positron tile at starting zoom level of 7.

The CartoDB maps tends to give a clean visual. The dark version of the map is available as cartodb darkmatter:

# Generate a folium map
sendai_lat_long = (38.268, 140.869)
m = folium.Map(location=sendai_lat_long, 
               tiles='cartodb darkmatter', 
               zoom_start=7)

# Show the map
show_folium_safe(m)
Figure 8: Map of Sendai using the cartodb darkmatter tile at starting zoom level of 7.

CartoDB also offers a base map with streets in the cartodb voyager tile:

# Generate a folium map
m = folium.Map(location=sendai_lat_long, 
               tiles='cartodb voyager', 
               zoom_start=7)

# Show the map
show_folium_safe(m)
Figure 9: Map of Sendai using the cartodb voyager tile at starting zoom level of 7.

Note that I did not bother getting an API key, which is why the maps above have the “API Key Required” watermark.

If you are thinking of using the sleek looking CartoDB map tiles for portfolio projects, be sure to obtain a free API key. If you decide to commercialize your idea, upgrade to an Enterprise account with CARTO.

3.3.3 OpenTopoMap

OpenTopoMap is a topographic map based on OpenStreetMap and NASA’s Shuttle Radar Topography Mission (SRTM) data. It is licensed as CC-BY-SA 3.0, which allows you to copy, modify and redistribute the material in any medium, even for commercial purposes, as long as you give proper credit. Again, folium takes care of this in the lower right corner of map tiles.

Additionally, because the OpenTopoMap project is based on the OpenStreetMap, if you decide to build upon the OpenTopoMap material, you must distribute it under the same license.

Here’s what the opentopomap map tile looks like:

# Generate a folium map
m = folium.Map(location=sendai_lat_long, 
               tiles='opentopomap', 
               zoom_start=7)

# Show the map
show_folium_safe(m)
Figure 10: Map of Sendai using the opentopomap tile at starting zoom level of 7.

The mountain ranges are shown in dark brown, and the depth of the ocean is also visible.

3.3.4 CyclOSM

CyclOSM is like an add-on layer to the OpenStreetMap base map, showing different roads for cycling. Its licensing is a bit complex, but given that you are only using the map tile, the license seems to be CC-BY-SA 2.0, which allows you to copy, adapt and redistribute the material in any medium, even for commercial purposes.

While there are not a lot of cycling roads documented in the Sendai regions, Figure 11 shows a CyclOSM map of the Sendai region:

# Generate a folium map
m = folium.Map(location=sendai_lat_long, 
               tiles='CyclOSM', 
               zoom_start=7)

# Show the map
show_folium_safe(m)
Figure 11: Map of Sendai using the CyclOSM tile at starting zoom level of 7.

The purple lines, one just below the center point of the map, and another near the lower left corner of the map outlining a peninsula, are cycling roads.

3.3.5 NASAGIBS ViirsEarthAtNight2012

The NASAGIBS ViirsEarhAtNight2012 map tile option provides an interesting view of the Earth at night. From the folium credits provided at the bottom of Figure 12, we see that the data is provided by NASA’s Global Imagery Browse Services (GIBS) which delivers NASA Earth Science observations through web services. The data itself is collected by the Visible Infrared Imaging Radiometer Suite (VIIRS), which collects visible and infrared imagery from aboard a satellite, while orbiting Earth.

Regardless of how the data was collected, NASA promotes full and open sharing of all data, metadata, documentation, models, images, research results, algorithms and source code. This policy allows users to use, distribute and modify the data for any purpose.

Here’s what the NASAGIBS ViirsEarhAtNight2012 map of the Sendai regions looks like:

# Generate a folium map
sendai_lat_long = (38.268, 140.869)
m = folium.Map(location=sendai_lat_long, 
               tiles='NASAGIBS ViirsEarthAtNight2012', 
               zoom_start=7,
               prefer_canvas=True)

# Show the map
show_folium_safe(m)
Figure 12: Map of Sendai using the NASAGIBS ViirsEarthAtNight2012 tile at starting zoom level of 7.

3.3.6 USGS USImageryTopo

If you are in need of a topographic map of the United States, you may consider using the USGS USImageryTopo tile. This is topographic data collected by the United States Geological Survey (USGS). While its licenses are a bit complicated, from what I understand, their map data seems to be in the public domain, allowing users to copy, modify and distribute to their liking, with proper attrition.

Here’s what the USGS USImageryTopo map tile looks like for California:

# Generate a folium map
california_lat_long = (36.7783, -119.4179)
m = folium.Map(location=california_lat_long, 
               tiles='USGS USImageryTopo', 
               zoom_start=7,
               prefer_canvas=True)

# Show the map
show_folium_safe(m)
Figure 13: Map of California using the USGS USImageryTopo tile at starting zoom level of 7.

Please note that the USGS USImageryTopo map tile contains detailed topographic information of the United States only. For topographic maps of other regions, you should use another map tile.

Now that we’ve seen a couple map tile styles and straightened out the different licenses associated with each, let’s go ahead and draw scatter plot like circle markers on the folium map.

4 Simple Scatter Plot like Visuals with folium.CircleMarker()

To demonstrate the use of folium.CircleMarker() in this tutorial, We’ll aim to replicate the map of the meteorological stations in the Sendai jurisdiction from my Japan Meteorological Agency (JMA) Stations Map Project. Figure 14 shows a screenshot of the map as a tab within my project.

Figure 14: A screenshot of the jurisdictional map of Sendai displaying various types of weather stations. The map contains mouse-over text, pop up text (covered in this blog post), as well as a matching legend (covered in another blog post). The aim of this blog post is to replicate the circle marker visuals of this map.

The map tile used is the openstreetmap.

# Specify the map tile
map_tile = 'openstreetmap'

We’ll import the pandas library to read in the data on the weather stations from a CSV file into a DataFrame.

# Data read with pandas
import pandas as pd

4.1 Read the Weather Station Location Data

You can access a CSV file called amedas_stations_all.csv inside the data/ subfolder in my GitHub repository. The CSV file contains data on all the weather stations in Japan. It has already been cleaned and preprocessed, and is ready for visualization on a map.

The complete details on how the data was cleaned are documented in the project details and documentations page. Alternatively, you can check out individual blog posts on how I opened CSV files with unknown encoding, or algorithmically crawled the Japan Meteorological Agency (JMA) website to retrieve URL information for each weather station, or transliterated Japanese words into the Roman alphabet, or converted Japanese dates into the Gregorian calendar.

The important thing to know is that the CSV file contains:

  • Names of the weather stations in both Japanese and English
  • prec_no, block_no and whether the station is of type a or s
  • GPS coordinates of the weather station in degrees and decimal minutes notation (not degrees, minutes, seconds) and its elevation
  • Types of weather data collected at each station
  • Height at which the anemometer and thermometer are set
  • Dates when various weather data collection began

Let’s go ahead and read the data as a pandas DataFrame.

# Read the CSV file containing all information 
fileName = './data/amedas_stations_all.csv'
amedas_df_all = pd.read_csv(fileName, encoding='utf-8')

To reduce the amount of clutter, we’ll get rid of all columns that won’t be used in this blog post. We’ll also limit the rows to those containing weather station data located in the Sendai region, which have a prec_no value between 31 and 36.

# Getting rid of the columns we won't be using
amedas_df = amedas_df_all.drop(["prefectural_bureau", 
                                "station_id", 
                                "katakana_name", 
                                "location", 
                                "observation_start_date", 
                                "weather_info_name", 
                                "notes1", 
                                "notes2"], axis=1)

# Using only the rows in the Sendai region
boolMask = (amedas_df['prec_no'] >= 31) & (amedas_df['prec_no'] <= 36)
sendai_df = amedas_df[boolMask].reset_index(drop=True)

# Take a peek at the resulting DataFrame
sendai_df.head()
prec_no block_no station_type url_station_type station_name romaji_name latitude_decimal longitude_decimal elevation observation_start_date_rain observation_start_date_other anemometer_height thermometer_height rainfall_YN temperature_YN wind_YN sunshine_YN relative_humidity_YN atmospheric_pressure_YN snowfall_YN
0 31 1041 a 大間 ooma 41.526667 140.911667 14 1975-05-24 1976-11-24 9.9 2 Y Y Y Y Y N Y
1 31 1559 a 湯野川 yunokawa 41.313333 140.956667 162 2005-10-21 2005-10-21 Y N N N N N N
2 31 47576 s むつ mutsu 41.283333 141.210000 3 1974-11-01 1975-12-10 11.1 1.5 Y Y Y Y Y Y Y
3 31 1122 a 小田野沢 odanosawa 41.235000 141.396667 6 1976-11-22 1976-11-22 9.9 2 Y Y Y Y Y N N
4 31 1026 a 今別 imabetsu 41.180000 140.481667 30 1975-05-21 1976-11-16 9.9 2 Y Y Y Y Y N Y

We’ll want to adjust the center of the map relative to these points. A simple .mean() method will work on the latitude and longitude columns.

# Find the middle of the map
mid_lat = sendai_df['latitude_decimal'].mean()
mid_long = sendai_df['longitude_decimal'].mean()

Now we are ready to draw the weather stations in the Sendai region on the folium maps.

To do so, we will draw a folium map centered on the group of weather stations in the Sendai region. We’ll read in the location of each weather station from the latitude_decimal and longitude_decimal columns inside the DataFrame, by iterating through sendai_df.itertuples(). Each weather station will be drawn as a circle on the map using folium.CircleMarker().

# Generate a folium map
m = folium.Map(location=[mid_lat, mid_long], 
               tiles=map_tile, 
               zoom_start=6, 
               min_zoom=5)

# Work through each row to plot a circle marker and generate popups 
for rowNow in sendai_df.itertuples():
    # Location of the weather station
    lat_now = float(rowNow.latitude_decimal)
    long_now = float(rowNow.longitude_decimal)

    # Make the marker and add it to the folium map
    markerNow = folium.CircleMarker(
        location=[lat_now, long_now]).add_to(m)

# Show the map
show_folium_safe(m)
Figure 15: Map of Sendai weather stations using mostly default parameters in folium.CircleMarker().

As you can see, the circle markers are overlapping at the starting zoom level, making the map difficult to decipher. Luckily, the marker sizes do not change as you zoom in, allowing for greater visibility once zoomed in.

However, we’d like each circle marker to be more distinct, even at the starting zoom level. As such, we’ll start by changing the size of the circle markers.

4.2 Change the Marker Size with radius

To change the size of the circle markers, simply adjust the radius parameter when calling folium.CircleMarker(). The radius parameter assigns a size in pixels to the marker. Since the default size is 10 pixels, we’ll want something smaller for this map.

We’ll let the marker sizes reflect the type of weather station, by varying radius according to the station_type in an if-else statement:

  • Set radius to 2 pixels for stations measuring just rainfall (), just snowfall (), or rainfall, temperature and wind ()
  • Set radius to 3 pixels for stations measuring rainfall, temperature, wind and relative humidity ()
  • Set radius to 5 pixels for the best equipped weather stations ()
# Generate a folium map
m = folium.Map(location=[mid_lat, mid_long], 
               tiles=map_tile, 
               zoom_start=6, 
               min_zoom=5)

# Work through each row to plot a circle marker and generate popups 
for rowNow in sendai_df.itertuples():
    # Location of the weather station
    lat_now = float(rowNow.latitude_decimal)
    long_now = float(rowNow.longitude_decimal)

    # Adjust size of marker and fill opacity according to station type
    if rowNow.station_type == '雨' or rowNow.station_type == '雪' or rowNow.station_type == '三':
        radius_now = 2
    elif rowNow.station_type == '四':
        radius_now = 3
    elif rowNow.station_type == '官':
        radius_now = 5

    # Make the marker and add it to the folium map
    markerNow = folium.CircleMarker(
        location=[lat_now, long_now], 
        radius=radius_now,  # Specify a marker size
        ).add_to(m)

# Show the map
show_folium_safe(m)
Figure 16: Map of Sendai weather stations with marker sizes varied.

Each individual scatter plot representing a weather station in Sendai are now clearly visible.

Next, let’s try changing the color of the circle markers.

4.3 Change the Marker Fill and Stroke Color with fill_color and color

You can specify fill_color for the circle marker fill, and color for the stroke color when calling the folium.CircleMarker() method. We can also assign a stroke width with the weight parameter, which defaults to 3 pixels.

In this map we’ll specify a base color:

# Set a base color for the Sendai jurisdiction
color_sendai = '#F78C6B'

In Figure 14, the stroke weight was 2 pixels, and the following coloring scheme for the markers were used:

  • For any weather station that recorded snowfall (snowfall_YN is Y), the stroke color (color) was dark grey ('#495057')
    • Additionally, if the weather station recorded only snowfall (station_type is ), the fill_color was set to white
    • Otherwise, the fill_color took on the specified base color
  • For any weather station that recorded just rainfall (station_type is ), both color and fill_color were set to light blue ('#90E0EF')
  • All other weather stations used the base color for both color and fill_color

Let’s update the folium map with this coloring scheme.

# Generate a folium map
m = folium.Map(location=[mid_lat, mid_long], 
               tiles=map_tile, 
               zoom_start=6, 
               min_zoom=5)

# Work through each row to plot a circle marker and generate popups 
for rowNow in sendai_df.itertuples():
    # Location of the weather station
    lat_now = float(rowNow.latitude_decimal)
    long_now = float(rowNow.longitude_decimal)

    # Adjust size of marker and fill opacity according to station type
    if rowNow.station_type == '雨' or rowNow.station_type == '雪' or rowNow.station_type == '三':
        radius_now = 2
    elif rowNow.station_type == '四':
        radius_now = 3
    elif rowNow.station_type == '官':
        radius_now = 5

    # Adjust marker fill and stroke colors
    if rowNow.snowfall_YN == 'Y':
        stroke_color = '#495057'
        if rowNow.station_type == '雪':
            fill_color = 'white'
        else:
            fill_color = color_sendai
    elif rowNow.station_type == '雨':
        fill_color = '#90E0EF'
        stroke_color = '#90E0EF'
    else:
        fill_color = color_sendai
        stroke_color = color_sendai

    # Make the marker and add it to the folium map
    markerNow = folium.CircleMarker(
        location=[lat_now, long_now], 
        radius=radius_now,
        weight=2,               # Set stroke weight to 2 pixels
        color=stroke_color,     # Adjust stoke color    
        fill_color=fill_color,  # Adjust fill color
        ).add_to(m)

# Show the map
show_folium_safe(m)
Figure 17: Map of Sendai weather stations with marker fill and stroke colors varied.

We’ve gotten quite a bit closer to Figure 14. All that remains for the markers is to have their opacities adjusted.

4.4 Change the Opacity of the Markers with fill_opacity and opacity

When calling folium.CircleMarker(), we can change the opacity of the marker fill with the fill_opacity parameter and the marker stroke opacity with the opacity parameter. In Figure 14, I set the stroke opacity to 1.0 (100 % opaque) and adjusted the circle marker fill opacities depending on the station_type:

  • Set fill_opacity to 0.5 (50 %) for stations measuring just rainfall (), or just snowfall ()
  • Set fill_opacity to 0.2 (20 %) for stations recording rainfall, temperature and wind ()
  • Set fill_opacity to 0.4 (40 %) for stations measuring rainfall, temperature, wind and relative humidity ()
  • Set fill_opacity to 0.8 (80 %) for the best equipped weather stations ()

Here’s what the updated folium map looks like:

# Generate a folium map
m = folium.Map(location=[mid_lat, mid_long], 
               tiles=map_tile, 
               zoom_start=6, 
               min_zoom=5)

# Work through each row to plot a circle marker and generate popups 
for rowNow in sendai_df.itertuples():
    # Location of the weather station
    lat_now = float(rowNow.latitude_decimal)
    long_now = float(rowNow.longitude_decimal)

    # Adjust size of marker and fill opacity according to station type
    if rowNow.station_type == '雨' or rowNow.station_type == '雪':
        radius_now = 2
        fill_opacity = 0.5
    elif rowNow.station_type == '三':
        radius_now = 2
        fill_opacity = 0.2
    elif rowNow.station_type == '四':
        radius_now = 3
        fill_opacity = 0.4
    elif rowNow.station_type == '官':
        radius_now = 5
        fill_opacity = 0.8

    # Adjust marker fill and stroke colors
    if rowNow.snowfall_YN == 'Y':
        stroke_color = '#495057'
        if rowNow.station_type == '雪':
            fill_color = 'white'
        else:
            fill_color = color_sendai
    elif rowNow.station_type == '雨':
        fill_color = '#90E0EF'
        stroke_color = '#90E0EF'
    else:
        fill_color = color_sendai
        stroke_color = color_sendai
    
    # Make the marker and add it to the folium map
    markerNow = folium.CircleMarker(
        location=[lat_now, long_now], 
        radius=radius_now,
        weight=2,
        color=stroke_color,
        opacity=1.0,                # Set marker stroke opacity to 1.0
        fill_color=fill_color,
        fill_opacity=fill_opacity,  # Set marker fill opacity to a desired amount
        ).add_to(m)

# Show the map
show_folium_safe(m)
Figure 18: Map of Sendai weather stations with marker fill and stroke opacities varied.

The circle markers now look identical to those in Figure 14.

Next, we’ll add the tooltip text that appears when the cursor hovers over each circle marker.

5 Hover Over Text with tooltip

The mouse-over, hover over or tooltip text can be set by assigning a string to the tooltip parameter when calling the folium.CircleMarker() method.

In our map of the weather stations in the Sendai region, we’d like to display the station name as written in English, followed by the station name in Japanese. Something like: “Sendai (仙台)”.

We can use an HTML formatted string literal that takes the romaji_name (station name in Roman alphabet), and station_name (Japanese station name).

mouseover_info = f"<h5>{rowNow.romaji_name.capitalize()} ({rowNow.station_name})</h5>"

The <h5>...</h5> is a simple way of increasing the text size.

Let’s go ahead and include the hover over text in the folium map:

# Generate a folium map
m = folium.Map(location=[mid_lat, mid_long], 
               tiles=map_tile, 
               zoom_start=6, 
               min_zoom=5)

# Work through each row to plot a circle marker and generate popups 
for rowNow in sendai_df.itertuples():
    # Location of the weather station
    lat_now = float(rowNow.latitude_decimal)
    long_now = float(rowNow.longitude_decimal)

    # Make the mouse over information
    mouseover_info = f"<h5>{rowNow.romaji_name.capitalize()} ({rowNow.station_name})</h5>"

    # Adjust size of marker and fill opacity according to station type
    if rowNow.station_type == '雨' or rowNow.station_type == '雪':
        radius_now = 2
        fill_opacity = 0.5
    elif rowNow.station_type == '三':
        radius_now = 2
        fill_opacity = 0.2
    elif rowNow.station_type == '四':
        radius_now = 3
        fill_opacity = 0.4
    elif rowNow.station_type == '官':
        radius_now = 5
        fill_opacity = 0.8

    # Adjust marker fill and stroke colors
    if rowNow.snowfall_YN == 'Y':
        stroke_color = '#495057'
        if rowNow.station_type == '雪':
            fill_color = 'white'
        else:
            fill_color = color_sendai
    elif rowNow.station_type == '雨':
        fill_color = '#90E0EF'
        stroke_color = '#90E0EF'
    else:
        fill_color = color_sendai
        stroke_color = color_sendai
    
    # Make the marker and add it to the folium map
    markerNow = folium.CircleMarker(
        location=[lat_now, long_now], 
        tooltip=mouseover_info,     # Add hover over text
        radius=radius_now,
        weight=2,
        color=stroke_color,
        opacity=1.0,
        fill_color=fill_color,
        fill_opacity=fill_opacity, 
        ).add_to(m)

# Show the map
show_folium_safe(m)
Figure 19: Map of Sendai weather stations with hover over text.

You can see the station names when the cursor hovers over each circle marker.

Let’s display more information on each weather station using pop up text.

6 Pop Up Text

Pop up text appears when the user clicks on a circle marker in the folium map. We can designate a string or a folium.Popup() object as the popup parameter in the folium.CircleMarker() method call.

The pop up text in my Japan weather stations map project that we are aiming to replicate, consists of the following components:

  • Station names in English and Japanese
  • prec_no, block_no, whether the station is a or s type
  • Station coordinates and elevation
  • Types of weather data collected in a table format
  • Thermometer and anemometer heights
  • When the station started collecting rainfall data, and all other data

We’ll gradually add these pop up components in the following sections. To make the new code bits more visible, we’ll group all code from the previous sections into a local function, MapSendaiJurisdiction_popup():

def MapSendaiJurisdiction_popup():
    """Function to generate the folium map (m) for the Sendai jurisdiction with popups
    AUTHOR:     Mai Tanaka (www.DataDrivenMai.com)
    DATE:       2026-08-24
    PROMISES: m = folium map object of the Sendai jurisdiction, complete with popups
    """

   # Generate a folium map
    m = folium.Map(location=[mid_lat, mid_long], 
                tiles='openstreetmap', 
                zoom_start=6, 
                min_zoom=5)

    # Work through each row to plot a circle marker and generate popups 
    for rowNow in sendai_df.itertuples():
        # Location of the weather station
        lat_now = float(rowNow.latitude_decimal)
        long_now = float(rowNow.longitude_decimal)

        # Make the mouse over information
        mouseover_info = f"<h5>{rowNow.romaji_name.capitalize()} ({rowNow.station_name})</h5>"

        # Make the popup information
        html_popup = GenerateHTML4Popup(rowNow)

        # Adjust size of marker and fill opacity according to station type
        if rowNow.station_type == '雨' or rowNow.station_type == '雪':
            radius_now = 2
            fill_opacity = 0.5
        elif rowNow.station_type == '三':
            radius_now = 2
            fill_opacity = 0.2
        elif rowNow.station_type == '四':
            radius_now = 3
            fill_opacity = 0.4
        elif rowNow.station_type == '官':
            radius_now = 5
            fill_opacity = 0.8

        # Adjust marker fill and stroke colors
        if rowNow.snowfall_YN == 'Y':
            stroke_color = '#495057'
            if rowNow.station_type == '雪':
                fill_color = 'white'
            else:
                fill_color = color_sendai
        elif rowNow.station_type == '雨':
            fill_color = '#90E0EF'
            stroke_color = '#90E0EF'
        else:
            fill_color = color_sendai
            stroke_color = color_sendai
        
        # Make the marker and add it to the folium map
        markerNow = folium.CircleMarker(
            location=[lat_now, long_now], 
            tooltip=mouseover_info, 
            popup=folium.Popup(     # Insert pop up text
                html=html_popup,
                max_width=300, 
                max_height=150),    
            radius=radius_now,
            weight=2,
            color=stroke_color,
            opacity=1.0,
            fill_color=fill_color,
            fill_opacity=fill_opacity, 
            ).add_to(m)

    # Return the map
    return m

In the above function, the pop up text is added to the folium.CircleMarker() method as the popup parameter. I also set the max_width of the pop up to 300 pixels, and the max_height of the pop up to 150 pixels, so any content that doesn’t fit in the box will be visible by scrolling within the pop up window.

The popup parameter takes in a folium.Popup() object within the folium.CircleMarker() method call. As such, the actual content of the pop up text is contained in the html parameter within the folium.Popup() method.

A local function, GenerateHTML4Popup(), generates the actual HTML content of the pop up text:

html_popup = GenerateHTML4Popup(rowNow)

So we need to create the local function, GenerateHTML4Popup(), to output an HTML containing the desired information for each weather station. Let’s start simple and make the local function add headings inside the pop up text.

6.1 Headings Inside the Pop Up Text

HTML headings are written in the format of:

<h1>Heading Name</h1>

Heading tags in HTML vary in levels from <h1>, the highest level with the largest text, to <h6>, the lowest level with the smallest text.

In the current map, we’ll use three headings inside the pop up text, at two different levels.

  • Station names in English and Japanese will be displayed in <h3> level
  • Types of weather data collected in a table format will be shown under “Data Collected” headings in the <h4> level
  • “Observation Start Dates” headings in the <h4> level will house the information on when the station started collecting rainfall data, and all other data

Thus, GenerateHTML4Popup() function containing just the section headings can be written as:

def GenerateHTML4Popup(arg_df_row):
    """Blog version 1: Just contains pop up text headings
    REQUIRES: arg_df_row = pandas dataframe row as .itertuples()
    PROMISES: html_popup = html format to pass onto folium html input in popups
    """
    
    # Make the html_popup with just the headings
    html_popup = f"""
    <h3>{arg_df_row.romaji_name.capitalize()} ({arg_df_row.station_name})</h3>
    <h4>Data Collected</h4>
    <h4>Observation Start Dates</h4>
    """   
    
    return html_popup

Creating a folium map with this function allows users to click on each station to display the three specified headings.

# Create the map with the pop up text containing just headings, and display it
m = MapSendaiJurisdiction_popup()
show_folium_safe(m)
Figure 20: Map of Sendai weather stations with pop up text containing just headings.

But of course, this is far from what we need. Let’s go ahead and add some informative text.

6.2 Text and Line Breaks Inside the Pop Up Text

The following information on the weather station can be added inside the formatted string by calling the column name inside the itertuple:

  • prec_no, block_no, whether the station is a or s type (url_station_type)
  • Station coordinates (latitude_decimal and longitude_decimal) and elevation
  • thermometer_height and anemometer_height
  • When the station started collecting rainfall data (observation_start_date_rain), and all other data (observation_start_date_other)

To make the information easy to read, we’ll add line breaks with <br> HTML tags after each data entry. The updated GenerateHTML4Popup() function now looks like this:

def GenerateHTML4Popup(arg_df_row):
    """Blog version 2: Contains headings, text and line breaks
    REQUIRES: arg_df_row = pandas dataframe row as .itertuples()
    PROMISES: html_popup = html format to pass onto folium html input in popups
    """
    
    # Make the html_popup with headings and informative text
    html_popup = f"""
    <h3>{arg_df_row.romaji_name.capitalize()} ({arg_df_row.station_name})</h3>
    prec_no: {arg_df_row.prec_no}
    <br>
    block_no: {arg_df_row.block_no}
    <br>
    a or s: {arg_df_row.url_station_type}
    <br>
    Location: {arg_df_row.latitude_decimal:.2f}°, {arg_df_row.longitude_decimal:.2f}°
    <br>
    Elevation: {arg_df_row.elevation} m
    <br>
    <br>    
    <h4>Data Collected</h4>
    Thermometer height: {arg_df_row.thermometer_height} m
    <br>
    Anemometer height: {arg_df_row.anemometer_height} m
    <br>
    <br>
    <h4>Observation Start Dates</h4>
    Rain: {arg_df_row.observation_start_date_rain}
    <br>
    Other: {arg_df_row.observation_start_date_other}
    """   
    
    return html_popup

Now, creating a map of the Sendai region with the MapSendaiJurisdiction_popup() generates a scrollable pop up text containing more specific information about each weather station.

# Create the map with the pop up text containing headings and some informative text, and display it
m = MapSendaiJurisdiction_popup()
show_folium_safe(m)
Figure 21: Map of Sendai weather stations with pop up text containing headings and some informative text.

All that remains to include in the pop up text is the table listing the type of weather data collected at each station.

6.3 Tables Inside the Pop Up Text

The basic syntax for creating an HTML table is as follows:

<table border="1">
    <tr>
        <td>row 1 cell 1</td>
        <td>row 1 cell 2</td>
    </tr>
    <tr>
        <td>row 2 cell 1</td>
        <td>row 2 cell 2</td>
    </tr>
</table>

The <table border="1"> specifies the thickness of the table borders to be 1 pixel wide.

We’ll display the information in the DataFrame’s rainfall_YN, temperature_YN, wind_YN, sunshine_YN, relative_humidity_YN, atmospheric_pressure_YN, and snowfall_YN columns inside the table. In other words, we’ll set up the table such that it contains the type of weather data in the first column (rainfall, temperature, wind, etc.), and whether that weather data was collected with Y for yes, and N for no in the second column.

We can further stylize the data entry in each cell using <span style=...>data entry</span> HTML tags. In the current map, we’ll change the font-weight of the Y or N text to bold, and use green or red text color.

<td><span style="color: {'green' or 'red'}; font-weight: bold;">{'Y' or 'N'}</span></td>

The updated GenerateHTML4Popup() function becomes something like this:

def GenerateHTML4Popup(arg_df_row):
    """Function to generate the HTML to insert into a popup in folium
    AUTHOR:     Mai Tanaka (www.DataDrivenMai.com)
    DATE:       2026-06-30
    REQUIRES: arg_df_row = pandas dataframe row as .itertuples()
    PROMISES: html_popup = html format to pass onto folium html input in popups
    """
    # Determine the color of the Y/N fonts for the data collected
    data_YN = [arg_df_row.rainfall_YN, 
               arg_df_row.temperature_YN, 
               arg_df_row.wind_YN, 
               arg_df_row.sunshine_YN, 
               arg_df_row.relative_humidity_YN, 
               arg_df_row.atmospheric_pressure_YN,
               arg_df_row.snowfall_YN]
    color_YN = []
    for data_YN_now in data_YN:
        if data_YN_now == 'Y':
            color_YN.append('green')
        else:
            color_YN.append('red')
    
    # Make the html_popup (contains a table of meteorological data collected)
    html_popup = f"""
    <h3> {arg_df_row.romaji_name.capitalize()} ({arg_df_row.station_name})</h3>
    prec_no: {arg_df_row.prec_no}
    <br>
    block_no: {arg_df_row.block_no}
    <br>
    a or s: {arg_df_row.url_station_type}
    <br>
    Location: {arg_df_row.latitude_decimal:.2f}°, {arg_df_row.longitude_decimal:.2f}°
    <br>
    Elevation: {arg_df_row.elevation} m
    <br>
    <br>    
    <h4>Data Collected</h4>
    <table border="1">
        <tr>
            <td>rainfall</td>
            <td><span style="color: {color_YN[0]}; font-weight: bold;">{data_YN[0]}</span></td>
        </tr>
        <tr>
            <td>temperature</td>
            <td><span style="color: {color_YN[1]}; font-weight: bold;">{data_YN[1]}</span></td>
        </tr>
        <tr>
            <td>wind direction/speed</td>
            <td><span style="color: {color_YN[2]}; font-weight: bold;">{data_YN[2]}</span></td>
        </tr>
        <tr>
            <td>sunshine</td>
            <td><span style="color: {color_YN[3]}; font-weight: bold;">{data_YN[3]}</span></td>
        </tr>
        <tr>
            <td>relative humidity</td>
            <td><span style="color: {color_YN[4]}; font-weight: bold;">{data_YN[4]}</span></td>
        </tr>
        <tr>
            <td>atmospheric pressure</td>
            <td><span style="color: {color_YN[5]}; font-weight: bold;">{data_YN[5]}</span></td>
        </tr>
        <tr>
            <td>snowfall</td>
            <td><span style="color: {color_YN[6]}; font-weight: bold;">{data_YN[6]}</span></td>
        </tr>
    </table>
    <br>
    Thermometer height: {arg_df_row.thermometer_height} m
    <br>
    Anemometer height: {arg_df_row.anemometer_height} m
    <br>
    <br>
    <h4>Observation Start Dates</h4>
    Rain: {arg_df_row.observation_start_date_rain}
    <br>
    Other: {arg_df_row.observation_start_date_other}
    """   
    
    return html_popup

The complete pop up text can now be drawn on the map:

# Create the map with the complete pop up text, and display it
m = MapSendaiJurisdiction_popup()
show_folium_safe(m)
Figure 22: Map of Sendai weather stations with headings, informative text and a table in the pop up text.

We can now draw a folium map with scatter plot like markers, complete with hover over and pop up text.

If you’d like, you can save the final map as an HTML with m.save():

# File directory and name
save_fileName = './data/sendai_map_scatter_plot.html'

# Save the file
m.save(save_fileName)

7 Summary

Congratulations! You can now draw an interactive folium map with scatter plot like markers. As a reminder, you learned how to:

  • Use folium.CircleMarker() to mark locations on a map much like a scatter plot
    • Modify the marker’s fill color, stroke color and opacity
  • Add mouse-over text with the tooltip parameter
  • Add pop up text with the mouseover parameter, which took in a folium.Popup() object, whose html parameter contained the content of the pop up

8 Further Readings