Build a rich, honest, interactive choropleth in Python with canvasxpress — quantile classes and a viridis ramp that survive heavy-tailed data, plus the area-bias trap and sequential vs diverging palettes.
Author
Isaac Neuhaus
Published
October 7, 2026
A choropleth shades each region by a value — emissions, incidence, turnout, income. It is the most common map in data analysis and the easiest to get subtly wrong: most “my map is all one colour” complaints are not rendering bugs but scale bugs. Here we build a choropleth in Python with canvasxpress that stays vivid and honest on exactly the kind of heavy-tailed data that flattens naive maps. The join and the colouring happen inside the render, so every figure is one portable spec you can re-run from Python, R or the browser.
Install
pip install canvasxpress pandas
First, the trap: colour encodes a rate, not a count
A choropleth fills an area, and area is a size channel your eye cannot help reading. Shade regions by a raw count — total cases, total emissions, total sales — and big, populous regions light up simply because they are big and populous. You have drawn a population map with extra steps.
The fix is almost always to map a rate or density: cases per 100k, emissions per capita, sales per store. Reserve raw counts for a proportional-symbol map (a sized bubble at each centroid), where the size channel carries the count honestly. Decide which question you are answering before you pick the geometry — this one choice prevents most misleading maps.
One value per region
A choropleth needs exactly one measurement per shape. In canvasxpress the columns (smps) are the regions — they match the map by name — and each row (vars) is a variable you can colour by. Here it’s CO₂ emissions per country for 2018. We load a prepared table; swap it for your own (any country → value table works).
import pandas as pddat = pd.read_csv("https://www.canvasxpress.org/data/r/cX-CO2-dat.txt", sep="\t", index_col=0,)smp = pd.read_csv("https://www.canvasxpress.org/data/r/cX-CO2-smp.txt", sep="\t", index_col=0,).reindex(dat.columns) # align annotation to the country orderprint(dat.shape) # (1, N): one row (CO2) x N countriesdat.iloc[:, :4]
(1, 198)
Afghanistan
Albania
Algeria
Andorra
CO2
10.452666
5.402999
164.309295
0.46421
The map: quantile classes on a viridis ramp
y carries the matrix (vars = the variable, smps = the countries, data = the values); the optional x block adds per-country annotations (continent, ISO code) for richer tooltips. The two choices that make this map work are in the colour scale: a seven-stop viridis colorSpectrum, and colorSpectrumBreaksMethod = "quantile", which puts an equal number of countries in every colour class. On skewed data that guarantee is what keeps all seven colours on the map.
from canvasxpress.canvas import CanvasXpressfrom canvasxpress.render.jupyter import CXNoteBookfrom IPython.display import displaydata = {"y": {"vars": list(dat.index), # ["CO2"]"smps": list(dat.columns), # country names"data": dat.values.tolist(), # [[values]] },"x": {"Continent": smp["Continent"].tolist(),"Code": smp["Code"].tolist(), },}config = {"graphType": "Map","colorBy": "CO2","topoJSON": "https://www.canvasxpress.org/data/maps/WORLD.json","mapId": "co2Quantile","colorSpectrum": ["#440154", "#443983", "#31688e", "#21918c","#35b779", "#90d743", "#fde725"],"colorSpectrumBreaksMethod": "quantile","mapColor": "#d9dde3","mapOutlineColor": "#ffffff","legendPosition": "bottom","title": "CO2 emissions per country (2018)","subtitle": "Seven quantile classes: each colour holds about 28 of 198 countries",}cx = CanvasXpress(render_to="co2quant", data=data, config=config, width=760, height=520)for part in CXNoteBook(cx).render(): display(part)
Every band of the ramp is doing work: the darkest purples are the smallest emitters, the yellows the largest, and the legend’s break values tell you the class edges are data-driven, not evenly spaced. Hover any country for its name, continent and value.
Why not just a plain linear ramp?
Because the data is heavy-tailed — a few countries emit thousands of megatonnes while most emit almost nothing. On a linear scale, 195 of these 198 countries fall into the bottom fifth of the range, so a continuous ramp paints nearly the whole world one pale tone and spends its colour on two or three outliers:
linear = {"graphType": "Map","colorBy": "CO2","topoJSON": "https://www.canvasxpress.org/data/maps/WORLD.json","mapId": "co2Linear","legendPosition": "left","theme": "solarized","title": "The same data on a plain linear ramp","subtitle": "195 of 198 countries sit in the bottom fifth of the scale - the map goes flat",}cx = CanvasXpress(render_to="co2lin", data=data, config=linear, width=700, height=480)for part in CXNoteBook(cx).render(): display(part)
This is the single most common choropleth failure. The cure is classing:
"colorSpectrumBreaksMethod": "quantile" — equal counts per class. Every colour is used, by construction. The right default for skewed data and for “which regions rank high/low” questions.
"colorSpectrumBreaksMethod": "jenks" — natural breaks that minimise within-class variance. Best when the data has genuine clusters with gaps between them; on smoothly heavy-tailed data like this it piles almost everything into the bottom class, so check the legend before you trust it.
Three to seven classes is the usable range — beyond that, readers cannot match colours to the legend.
Choose the palette on purpose
The palette is the scale’s grammar, not decoration:
Sequential (dark → light of ordered hues, like viridis above) for a quantity that only goes up: counts, rates, concentrations.
Diverging (two hues meeting at a neutral midpoint) when values are meaningful above and below a reference: change vs. last year, difference from a target, a signed index. Give a three-colour colorSpectrum and pin the midpoint with colorSpectrumZeroValue:
"colorBy": "Change","colorSpectrum": ["#2166ac", "#f7f7f7", "#b2182b"], # blue - neutral - red"colorSpectrumZeroValue": 0, # value mapped to the middle colour
Never use a diverging palette for a purely positive quantity — the neutral midpoint invents a “normal” the data does not have.
Viridis and the ColorBrewer ramps are colour-blind-safe and perceptually ordered; a rainbow ramp is neither, so readers cannot tell which end means “more”. Keep the same scale across small multiples so maps stay comparable.
mapColor / mapOutlineColor — the fill for no-data regions (a neutral grey) and the shape borders, so missing data reads as missing rather than as a value.
"showMapLabels": True — draw region names (best on few-region maps; clutters a world map).
"legendInside": True — float the legend over empty ocean to reclaim width.
Beyond the world map
Other boundaries. Point topoJSON at any shape file — US states, a country’s provinces, counties, custom districts. The join key is just the shape name.
Projection."mapProjection": "albers" (equal-area) or "mercator". Equal-area projections matter for choropleths: Mercator inflates high latitudes and exaggerates their visual weight.
Categorical maps. Point colorBy at a categorical column for a qualitative choropleth with a discrete legend.
Markers. Add decorations to drop labelled points on cities or call out specific regions.
The whole figure — data, boundaries, classing rule and palette — is one portable spec you can store, validate and re-render from R or JavaScript.