Skip to content

plotly express choropleth

Color every country or state on a map according to a number in your data frame — one function call at the end of a method chain.

What px.choropleth actually does

How does Plotly know where Chad is?

It knows because you tell it. You hand px.choropleth a column of place names or place codes and a column of numbers, and it paints each place in a color drawn from the numbers. The geography — every border, every coastline — Plotly already has.

That makes this the rare chart where the hard part is not the plotting call. It is getting your places to agree with Plotly's places. locations names the column that identifies each area, and locationmode says what kind of identifier that column holds: 'ISO-3' (the default) for three-letter country codes, 'country names' for spelled-out names, or 'USA-states' for two-letter state abbreviations. Get that pairing wrong and nothing raises. You get a map, and some of your data is just not on it.

One row per area is the shape you want, which is why a groupby and a reset_index almost always sit directly above the call. What comes back is a plotly.graph_objects.Figure, so it renders itself as the last expression of a Jupyter or marimo cell, and needs fig.show() anywhere else.

Official documentation: plotly.express.choropleth, plus the Plotly guides to choropleth maps and map configuration.

Write it with pipe

The signature in the documentation looks like this:

px.choropleth(df, locations='iso_code', color='co2_per_capita')

That works. But I never write it that way, because the data frame going into a map is almost never the one I read from disk — it has been grouped, summed, and had its index pushed back into a column first. So the chart goes on the end of the chain:

(
    df
    .groupby('State', as_index=False)['amount'].sum()
    .pipe(px.choropleth, locations='State', locationmode='USA-states',
          color='amount', scope='usa')
)

pipe hands the data frame on its left to the function as that function's first argument, and forwards every keyword argument straight through. This is not a stylistic preference I am recommending to you and ignoring myself: px.choropleth appears in 10 Bamboo Weekly solutions, in 13 separate calls, and every single one of them is written with pipe.

The arguments that earn their keep

px.choropleth(df,
              locations='iso_code',            # the column identifying each area
              locationmode='ISO-3',            # ISO-3 | country names | USA-states
              color='co2_per_capita',          # the column driving the color
              scope='world',                   # world, usa, europe, asia, africa,
                                               # north america, south america, oceania
              projection='robinson',           # mostly matters at world scope
              color_continuous_scale='viridis',
              range_color=(0, 20),             # pin the scale; ignore the outliers
              hover_name='country',            # bold first line of the tooltip
              labels={'co2_per_capita': 'tonnes per person'},
              title='...')

locations and color are the two you cannot skip. scope is the one that most improves a map: the default draws the whole globe, so a chart about Europe arrives as a small colored smudge with two oceans around it. projection is worth a minute of play — 'robinson', 'natural earth', and 'orthographic' all read better than the default equirectangular, which stretches the Arctic into a wall. It is a world-scale argument, though; once you narrow the scope, Plotly already has a projection suited to the region.

hover_name is the small courtesy that makes a map readable. Plot by ISO-3 code and the tooltip says iso_code=QAT, which helps nobody; hover_name='country' puts Qatar in bold at the top of it.

A worked example, on real data

Our World in Data publishes a single CSV covering every country's CO2 emissions, its population, and its emissions per person. Here is 2024:

import pandas as pd
from plotly import express as px

url = 'https://raw.githubusercontent.com/owid/co2-data/master/owid-co2-data.csv'

df = (
    pd.read_csv(url, usecols=['country', 'iso_code', 'year',
                              'population', 'co2', 'co2_per_capita'])
    .loc[lambda df_: df_['year'].eq(2024)]
    .dropna(subset=['co2'])
)
df.head()
           country  year iso_code    population       co2  co2_per_capita
274    Afghanistan  2024      AFG  4.264750e+07    10.826           0.254
549         Africa  2024      NaN  1.514032e+09  1502.099           0.993
724   Africa (GCP)  2024      NaN           NaN  1502.087             NaN
899        Albania  2024      ALB  2.791756e+06     4.444           1.592
1074       Algeria  2024      DZA  4.681430e+07   198.203           4.234

Row 549 is already the problem. There is no country called Africa, and there is certainly no country called Africa (GCP). Pipe all 247 rows straight in and Plotly will accept every one of them:

fig = df.pipe(px.choropleth, locations='country',
              locationmode='country names', color='co2')

len(df), len(fig.data[0].locations)
(247, 247)

All 247 locations went into the trace, and Python was perfectly happy. Thirty-two of them cannot be drawn, because they are continents, income bands, and accounting categories rather than places. The name-to-border lookup happens in the browser, in JavaScript, long after Python has stopped running, so there is no exception and no warning — only a map that is missing things you never notice are missing.

This file ships an iso_code column, so the aggregates are easy to spot and drop:

df['iso_code'].isna().sum()
32

Most files are not that generous. The general technique is to check your names against a real list of countries before you plot, which is an ordinary left join with indicator=True:

iso = pd.read_csv('https://raw.githubusercontent.com/lukes/'
                  'ISO-3166-Countries-with-Regional-Codes/master/all/all.csv',
                  usecols=['name', 'alpha-3'])

(
    df
    .merge(iso, left_on='country', right_on='name',
           how='left', indicator=True)
    .loc[lambda df_: df_['_merge'].eq('left_only'), 'country']
    .head(10)
    .tolist()
)
['Africa', 'Africa (GCP)', 'Asia', 'Asia (GCP)',
 'Asia (excl. China and India)', 'Bolivia',
 'Bonaire Sint Eustatius and Saba', 'British Virgin Islands',
 'Brunei', 'Cape Verde']

Fifty-nine of the 247 names are not in the ISO list. That is a wider net than Plotly's own, which recognizes plenty of everyday short forms, but the list is exactly what you want to read: the aggregates at the top must go, and the rest — Bolivia, Cape Verde, Cote d'Ivoire — are countries whose official names differ from their common ones. That is the same problem merge runs into on every international data set, and it is fixed the same way, in the strings rather than in the plotting call.

Drop the aggregates and 215 countries remain — at which point the second problem appears:

real = df.dropna(subset=['iso_code'])

real['co2'].describe()[['50%', 'max']]
50%       10.826
max    12289.037
Name: co2, dtype: float64

China emits 12,289 million tonnes. The median country emits 10.8. On a scale that has to stretch from one to the other, 207 of the 215 countries land below five percent of the maximum, which is to say they are all the same color.

Which raises the deeper question. Should this be total emissions at all?

real[['co2', 'co2_per_capita']].corrwith(real['population']).round(3)
co2               0.824
co2_per_capita   -0.002
dtype: float64

Total emissions correlate with population at 0.82; emissions per person correlate with it at essentially zero. The totals map is, in large part, a population map wearing a different label. The per-person map is a genuinely different picture:

real.nlargest(6, 'co2_per_capita')[['country', 'co2', 'co2_per_capita']]
                   country      co2  co2_per_capita
37960                Qatar  125.812          41.271
24865               Kuwait  129.519          26.248
7662                Brunei   12.052          26.046
4514               Bahrain   39.003          24.270
45799  Trinidad and Tobago   34.576          22.932
40289         Saudi Arabia  692.133          20.379

Qatar is 38th in the world by total emissions and first by a wide margin per person. So the honest map uses co2_per_capita, ISO-3 codes rather than names, and range_color to stop Qatar's 41 tonnes from flattening everyone else:

(
    real
    .pipe(px.choropleth,
          locations='iso_code',
          color='co2_per_capita',
          hover_name='country',
          color_continuous_scale='viridis',
          range_color=(0, 20),
          projection='robinson',
          labels={'co2_per_capita': 'tonnes per person'},
          title='CO2 emissions per person, 2024')
)

No locationmode this time, because ISO-3 is the default. range_color=(0, 20) clips the seven countries above 20 tonnes to the brightest color and gives the other 206 the full range of the scale. That is the trade you almost always want: legibility for the many, at the cost of detail for the few.

Four mistakes people make

Places that do not match simply vanish. This is what makes choropleths different from every other chart. A typo in a column name gives you ValueError: Value of 'locations' is not the name of a column in 'data_frame', loudly and immediately. A typo in a country name gives you a map. Compare your names against a reference list before you plot, and read what fell out. In Bamboo Weekly #172 the World Cup winners included West Germany and England, neither of which is a country Plotly draws, so the chain carries a .replace({'West Germany': 'Germany', 'England': 'United Kingdom'}) — a line that exists purely because two teams would otherwise have disappeared without comment.

One outlier eating the whole color scale. Leave the aggregate rows in the CO2 data and World sets the maximum at 38,599, so every actual country is the same shade. Remove them and China still sets it at 12,289. Plotly does not choose the range in Python at all — fig.layout.coloraxis.cmin and cmax are both None until you say otherwise, and the browser fits the scale to whatever it is handed. A .describe() before you plot is how you know you need range_color.

Mapping raw counts when the honest measure is a rate. Any count aggregated by country or by state is, first and foremost, a map of where the people are. Total CO2 against population correlates at 0.82 here; per-person emissions correlate at −0.002. I walked straight into this in Bamboo Weekly #182, mapping surveillance-technology purchases by US state and expecting the South and Midwest to light up. What I got was a map of California, Texas, and Florida, and I said so in the post: of course, that is the number of purchases, and those are the three most populous states. Divide by population, or by area, or by whatever denominator the question actually implies. If you cannot find one, say in the title that the chart shows totals, so a reader is not invited to read it as intensity.

scope='usa' wants state codes, not state names. With locationmode='USA-states' the locations column must hold two-letter postal abbreviations — CA, IA, IL. Pass Alabama and Alaska instead and the figure builds without complaint, fig.data[0].locations holds exactly the strings you gave it, and the map comes up blank. The USDA agricultural exports file that Plotly ships as a sample has both columns side by side, state and code, which tells you how routinely people reach for the wrong one:

url = 'https://raw.githubusercontent.com/plotly/datasets/master/2011_us_ag_exports.csv'

(
    pd.read_csv(url)
    .pipe(px.choropleth, locations='code', locationmode='USA-states',
          color='total exports', scope='usa', hover_name='state',
          color_continuous_scale='spectral')
)

Where it shows up in Bamboo Weekly

Bamboo Weekly #157: Government corruption is the one to read first, because the choropleth section sits before the paywall. It maps Transparency International's Corruption Perceptions Index with locations='Country / Territory', locationmode='country names', and projection='robinson' — and then does something worth stealing. The rank runs from 1 for the cleanest country to 181 for the worst, so the bright colors landed on the dictatorships. Rather than hunt for a reversed color scale, the solution adds a column: .assign(reverse_rank = pd.col('Rank').max() - pd.col('Rank')), and colors by that instead.

Bamboo Weekly #158: University endowments draws the same US map three times, by state total, then by mean change, then by median change, with locationmode='USA-states', scope='usa', and color_continuous_scale='spectral'. Seeing one map redrawn on mean and then median is the clearest argument I know for why the aggregation function matters as much as the chart.

Bamboo Weekly #182: Surveillance technology is the shortest complete example: groupby('State', as_index=False), a count, and a pipe into px.choropleth. It is also where the per-capita question comes up, as described above.

Bamboo Weekly #160: Strait of Hormuz shows the pattern for a regional map: join a country table onto the oil-export figures, then scope='asia' and color_continuous_scale='spectral'. Those last three are for paid subscribers.

Practice it

Work through a px.choropleth() exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/plotly-choropleth/

Go deeper

The work in a choropleth happens before the chart, so read the pages about getting to one row per place: groupby and reset_index for the aggregation, merge for attaching population or ISO codes, assign for computing the rate, and pipe for keeping all of it in one expression. On the Plotly side, map configuration covers scope and the projections, and built-in color scales shows what is available beyond the default.

Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.

Part of the Pandas Methods Index. See also practice by skill.