My GeoPandas Choropleth Colours Look Wrong: How to Fix It
Problem statement
The map renders and the colours make no sense.
The whole thing is one shade of pale yellow with two dark red polygons. Or it is a rainbow patchwork with no visible pattern. Or the highest-income wards are pale and the lowest are dark, exactly backwards. Or half the map is missing and the holes look like zeros. Or the "no change" colour on a diverging map sits somewhere other than zero, so growth and decline are not comparable.
gdf.plot(column="income", cmap="YlOrRd", legend=True)
No error, no warning. The map is a faithful rendering of a set of decisions you did not know you were making β mostly about classification, outliers, missing values and colour direction.
There are seven distinct causes. Each has a specific tell, and most take one line to fix.
Quick answer
Look at the data before blaming the plot:
v = gdf["income"]
print(f"n={len(v):,} missing={v.isna().sum():,} "
f"dtype={v.dtype} skew={v.skew():+.2f}")
print(v.describe(percentiles=[.01, .25, .5, .75, .95, .99]).round(1).to_string())
| What you see | Cause | Fix |
|---|---|---|
| one colour, a few extremes | no scheme, or an outlier | scheme="quantiles", or clip |
| everything in class 1 | equal_interval on skewed data |
scheme="quantiles" |
| colours look random | column is a string, not a number | pd.to_numeric(...) |
| high values look pale | reversed colormap | cmap="YlOrRd" not "YlOrRd_r" |
| holes in the map | nulls not drawn | missing_kwds={...} |
| neutral colour is not at zero | asymmetric diverging range | vmin=-lim, vmax=lim |
| pattern follows population | mapping a count, not a rate | divide by a denominator |
gdf.plot(column="income", scheme="quantiles", k=5, cmap="YlOrRd",
legend=True, legend_kwds={"fmt": "{:,.0f}"},
missing_kwds={"color": "#e2e8f0", "hatch": "///", "label": "no data"})
Step-by-step solution
1. One colour with a couple of extremes β an outlier is eating the ramp
The default is a continuous linear stretch from minimum to maximum. One extreme value compresses everything else into a sliver of the ramp:
v = gdf["income"]
print(f"p95 {v.quantile(0.95):,.0f} max {v.max():,.0f} "
f"ratio {v.max() / v.quantile(0.95):.1f}Γ")
p95 78,404 max 412,004 ratio 5.3Γ
A maximum five times the 95th percentile means 95% of the data occupies the bottom 19% of the colour range. Three fixes, in order of preference:
# (a) classify β the real answer
gdf.plot(column="income", scheme="quantiles", k=5, cmap="YlOrRd", legend=True)
# (b) clip the continuous range at percentiles
lo, hi = v.quantile([0.02, 0.98])
gdf.plot(column="income", cmap="YlOrRd", vmin=lo, vmax=hi, legend=True)
# (c) log scale, for genuinely multiplicative data
from matplotlib.colors import LogNorm
gdf.plot(column="income", cmap="YlOrRd", norm=LogNorm(v.min(), v.max()), legend=True)
Classification is the durable fix, because it makes the breaks explicit and puts them in the legend. Clipping hides the extremes rather than accounting for them, so say so in the caption when you use it.
2. Everything in the first class β the wrong scheme for the distribution
import mapclassify as mc
for scheme in ["equal_interval", "quantiles", "natural_breaks"]:
c = mc.classify(v.dropna(), scheme, k=5)
counts = list(c.counts)
print(f"{scheme:<16} counts {counts} largest class "
f"{100*max(counts)/sum(counts):.0f}%")
equal_interval counts [8218, 174, 32, 8, 4] largest class 97%
quantiles counts [1688, 1687, 1687, 1687, 1687] largest class 20%
natural_breaks counts [6412, 1604, 336, 68, 16] largest class 76%
equal_interval divides the value range evenly, which on right-skewed data β income, density, prices, case counts β puts nearly everything in the first class. A largest-class share above about 60% means the scheme is not separating your data. Full comparison in choropleth classification explained.
3. Colours look random β the column is not numeric
print(gdf["income"].dtype) # object
print(gdf["income"].head(3).tolist())
object
['Β£31,880', 'Β£24,204', 'n/a']
A string column is treated as categorical, so each distinct value gets an arbitrary colour and any ordering is coincidence.
gdf["income"] = pd.to_numeric(
gdf["income"].astype(str)
.str.replace(r"[Β£$,\s]", "", regex=True)
.replace({"n/a": None, "-": None, "": None}),
errors="coerce")
print(f"{gdf['income'].isna().sum():,} values could not be parsed")
errors="coerce" turns unparseable values into NaN rather than raising, which is right here β but print the count, because a silent 40% failure produces a map of the 60% that parsed. This is ordinary attribute cleaning, and it belongs before the map, not in it.
4. High values look pale β the colormap is reversed
Matplotlib's _r suffix reverses a colormap, and some ramps are counter-intuitive without it:
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
for ax, cmap in zip(axes, ["YlOrRd", "YlOrRd_r"]):
gdf.plot(column="income", scheme="quantiles", k=5, cmap=cmap, ax=ax, legend=True)
ax.set_title(cmap); ax.set_axis_off()
Beyond the suffix, check the ramp's direction matches the message. YlOrRd, Blues, viridis all run light β dark as values increase, which reads as "more". RdYlGn runs red β green, which carries a value judgement: green as "good" is right for income and wrong for pollution. RdYlGn_r flips it.
5. Holes in the map β missing values are not being drawn
print(f"{gdf['income'].isna().sum()} of {len(gdf)} features have no value")
By default, features with a null value are simply not drawn. The polygon disappears, leaving a hole a reader interprets as zero, or as an error in the boundaries.
gdf.plot(column="income", scheme="quantiles", k=5, cmap="YlOrRd", legend=True,
missing_kwds={"color": "#e2e8f0", "edgecolor": "#cbd5e1",
"hatch": "///", "label": "no data"})
The hatch matters as much as the grey: a flat grey can be read as another class, while a hatch reads as "not applicable" at a glance. And "label" puts it in the legend, which is the part that makes it unambiguous. See how to handle missing and null values.
Also check for implicit missing values β a 0 or -999 standing in for "unknown" is worse than a null, because it plots as a real class:
print(gdf["income"].value_counts().head(5))
6. A diverging map whose neutral colour is not at zero
gdf["change"] = (gdf["pop_2026"] - gdf["pop_2016"]) / gdf["pop_2016"] * 100
print(f"range {gdf['change'].min():+.1f}% β¦ {gdf['change'].max():+.1f}%")
range -8.2% β¦ +41.7%
With a RdBu ramp and no explicit range, matplotlib centres the neutral colour at the midpoint of the data β here about +16.8%. So a ward that grew 10% is coloured as if it declined. Force symmetry:
import numpy as np
lim = np.abs(gdf["change"]).quantile(0.98) # ignore the extreme tail
gdf.plot(column="change", cmap="RdBu", vmin=-lim, vmax=lim, legend=True,
legend_kwds={"label": "Population change (%)"})
Or use TwoSlopeNorm when the tails are genuinely asymmetric and clipping would lose information:
from matplotlib.colors import TwoSlopeNorm
norm = TwoSlopeNorm(vmin=gdf["change"].min(), vcenter=0, vmax=gdf["change"].max())
gdf.plot(column="change", cmap="RdBu", norm=norm, legend=True)
TwoSlopeNorm keeps zero at the neutral colour while using the full ramp on both sides β at the cost that a given colour distance means different amounts above and below zero. Say which you used.
7. The pattern follows population β you are mapping a count
If the map's high values are exactly the cities, check what is being coloured:
print(gdf[["cases", "population"]].corr().iloc[0, 1]) # 0.94
A correlation of 0.94 between the mapped column and population means the map is a population map. A choropleth colours areas, and the reader integrates colour over area, so a raw count shows where the denominator is large.
gdf["cases_per_100k"] = gdf["cases"] / gdf["population"].replace(0, np.nan) * 100_000
gdf.plot(column="cases_per_100k", scheme="quantiles", k=5, cmap="YlOrRd", legend=True)
This is the single highest-value correction in the whole article, and it is not a plotting fix at all.
Code examples
Example 1: a diagnostic that names the cause
import numpy as np
import pandas as pd
import mapclassify as mc
def diagnose_choropleth(gdf, column, *, scheme=None, k=5,
cmap="YlOrRd", denominator_candidates=("population", "pop", "households")):
problems = []
v = gdf[column]
if not pd.api.types.is_numeric_dtype(v):
sample = v.dropna().astype(str).head(3).tolist()
return [f"'{column}' is {v.dtype}, not numeric β e.g. {sample}. "
f"Use pd.to_numeric(..., errors='coerce') first."]
n_missing = v.isna().sum()
if n_missing:
problems.append(f"{n_missing:,} of {len(gdf):,} features have no value β "
f"pass missing_kwds or they will not be drawn")
vv = v.dropna()
if vv.empty:
return [f"'{column}' is entirely null"]
for sentinel in (0, -1, -999, -9999):
n = (vv == sentinel).sum()
if n and n / len(vv) > 0.02:
problems.append(f"{n:,} values equal {sentinel} β a placeholder for missing?")
if vv.max() > 0 and vv.quantile(0.95) > 0:
ratio = vv.max() / vv.quantile(0.95)
if ratio > 3:
problems.append(f"max is {ratio:.1f}x the 95th percentile β one outlier will "
f"flatten a continuous ramp; classify or clip")
if scheme is None:
problems.append("no scheme passed β the continuous default is the least readable "
"option; try scheme='quantiles'")
else:
counts = list(mc.classify(vv, scheme, k=k).counts)
share = 100 * max(counts) / sum(counts)
if share > 60:
problems.append(f"scheme '{scheme}' puts {share:.0f}% of features in one "
f"class (counts {counts}) β try quantiles or natural_breaks")
if (vv < 0).any() and (vv > 0).any():
if not cmap.rstrip("_r") in {"RdBu", "PiYG", "BrBG", "coolwarm", "RdYlBu", "Spectral"}:
problems.append(f"values cross zero but '{cmap}' is a sequential ramp β "
f"use a diverging one with symmetric vmin/vmax")
for cand in denominator_candidates:
if cand in gdf.columns and pd.api.types.is_numeric_dtype(gdf[cand]):
r = vv.corr(gdf.loc[vv.index, cand])
if r is not None and r > 0.8:
problems.append(f"'{column}' correlates {r:.2f} with '{cand}' β this may "
f"be a count; map {column}/{cand} instead")
break
return problems or ["no obvious problem β check the colormap direction by eye"]
for p in diagnose_choropleth(gdf, "cases", scheme="equal_interval", k=5):
print(f" β {p}")
β 412 of 8,436 features have no value β pass missing_kwds or they will not be drawn
β max is 6.2x the 95th percentile β one outlier will flatten a continuous ramp; classify or clip
β scheme 'equal_interval' puts 97% of features in one class (counts [8218, 174, 32, 8, 4]) β try quantiles or natural_breaks
β 'cases' correlates 0.94 with 'population' β this may be a count; map cases/population instead
Four findings from one call, and the last one is the important one β the others are about how the map looks, and that one is about what it says. The thresholds are heuristics chosen to be loud rather than precise; a false positive costs a moment's thought, and a false negative ships.
Example 2: the same data, seven ways
Seeing the failure modes together makes them recognisable afterwards:
import matplotlib.pyplot as plt
import numpy as np
def failure_gallery(gdf, column, denominator=None):
v = gdf[column]
lo, hi = v.quantile([0.02, 0.98])
panels = [
("default β continuous", dict(column=column, cmap="YlOrRd")),
("equal_interval", dict(column=column, cmap="YlOrRd",
scheme="equal_interval", k=5)),
("quantiles", dict(column=column, cmap="YlOrRd",
scheme="quantiles", k=5)),
("natural_breaks", dict(column=column, cmap="YlOrRd",
scheme="natural_breaks", k=5)),
("clipped 2β98%", dict(column=column, cmap="YlOrRd",
vmin=lo, vmax=hi)),
("reversed ramp", dict(column=column, cmap="YlOrRd_r",
scheme="quantiles", k=5)),
("nulls shown", dict(column=column, cmap="YlOrRd",
scheme="quantiles", k=5,
missing_kwds={"color": "#e2e8f0",
"hatch": "///"})),
]
if denominator:
gdf = gdf.assign(_rate=gdf[column] / gdf[denominator].replace(0, np.nan))
panels.append(("as a rate", dict(column="_rate", cmap="YlOrRd",
scheme="quantiles", k=5)))
ncols = 4
nrows = (len(panels) + ncols - 1) // ncols
fig, axes = plt.subplots(nrows, ncols, figsize=(4.2 * ncols, 4.4 * nrows))
for ax, (title, kwargs) in zip(axes.ravel(), panels):
gdf.plot(ax=ax, edgecolor="white", linewidth=0.15, **kwargs)
ax.set_title(title, fontsize=9)
ax.set_axis_off()
for ax in axes.ravel()[len(panels):]:
ax.set_axis_off()
plt.tight_layout()
return fig
failure_gallery(gdf, "cases", denominator="population")
Compare the first panel with the third and the last. The first is nearly uniform; the third is legible but shows where people are; the last shows where the rate is high, which is what the legend claimed all along.
Example 3: a plotting function that refuses the common mistakes
import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
DIVERGING = {"RdBu", "PiYG", "BrBG", "coolwarm", "RdYlBu", "Spectral", "PuOr"}
def safe_choropleth(gdf, column, *, denominator=None, per=1, scheme="quantiles",
k=5, cmap=None, crs=None, title=None, clip=None, ax=None):
gdf = gdf.copy()
if not pd.api.types.is_numeric_dtype(gdf[column]):
raise TypeError(f"'{column}' is {gdf[column].dtype}; convert with pd.to_numeric first")
col = column
if denominator:
col = f"{column}_rate"
gdf[col] = gdf[column] / gdf[denominator].replace(0, np.nan) * per
if crs:
gdf = gdf.to_crs(crs)
elif gdf.crs is not None and gdf.crs.is_geographic:
raise ValueError("geographic CRS β pass crs= (ideally equal-area) for a choropleth")
v = gdf[col]
crosses_zero = bool((v.dropna() < 0).any() and (v.dropna() > 0).any())
cmap = cmap or ("RdBu" if crosses_zero else "YlOrRd")
if crosses_zero and cmap.rstrip("_r") not in DIVERGING:
raise ValueError(f"values cross zero β '{cmap}' is sequential; use a diverging ramp")
kwargs = dict(cmap=cmap, edgecolor="white", linewidth=0.25, legend=True,
missing_kwds={"color": "#e2e8f0", "edgecolor": "#cbd5e1",
"hatch": "///",
"label": f"no data ({v.isna().sum():,})"})
if crosses_zero: # symmetric range so neutral sits at zero
lim = np.abs(v).quantile(clip or 0.98)
kwargs.update(vmin=-lim, vmax=lim,
legend_kwds={"label": col.replace("_", " ")})
elif clip:
lo, hi = v.quantile([clip, 1 - clip])
kwargs.update(vmin=lo, vmax=hi, legend_kwds={"label": col.replace("_", " ")})
else:
kwargs.update(scheme=scheme, k=k,
legend_kwds={"loc": "lower right", "fmt": "{:,.0f}",
"title": col.replace("_", " "), "fontsize": 9})
if ax is None:
_, ax = plt.subplots(figsize=(10, 11))
gdf.plot(column=col, ax=ax, **kwargs)
ax.set_title(title or col, fontsize=14, loc="left")
ax.set_axis_off()
return ax
ax = safe_choropleth(gdf, "cases", denominator="population", per=100_000,
crs=27700, title="Cases per 100,000 residents")
Four refusals and two automatic choices. It raises on a non-numeric column and on a geographic CRS, both of which produce a plausible-looking wrong map. It picks a diverging ramp automatically when the values cross zero, and refuses a sequential one in that case. It always shows missing values, with a count in the legend. And when the range is symmetric it centres the neutral colour on zero rather than on the data's midpoint.
Raising rather than warning is deliberate. A warning in a notebook scrolls past; the map does not.
Explanation
A choropleth is a composition of three independent mappings, and "the colours look wrong" is always a failure in one of them.
Value β number. The column has to be numeric and has to mean what you think. A string column silently becomes categorical; a -999 placeholder becomes a legitimate extreme; a count becomes a proxy for population. None of these are plotting problems, and none of them can be fixed by changing the colormap.
Number β class. This is where most of the visible failures live, and the reason is the shape of real spatial data. Income, density, prices, case counts and property values are right-skewed almost without exception, and the default continuous ramp is a linear map from value to colour. Linear maps and skewed data do not mix: the top 1% of values claim most of the colour range and the other 99% share what is left. Classification is the fix because it replaces a linear map with one derived from the distribution β and different schemes derive it differently, which is why equal_interval and quantiles produce such different maps from identical numbers.
Class β colour. The smallest of the three and still worth care. Sequential ramps encode magnitude and must run in the direction the reader expects. Diverging ramps encode deviation from a meaningful centre, and that centre must actually be at the centre β matplotlib puts the neutral colour at the midpoint of the range, which is only zero by coincidence. And a categorical palette on ordered data destroys the ordering that the data has.
Missing values are a fourth thing that behaves like a colour bug. GeoPandas does not draw features whose value is null, so they vanish. A reader seeing a hole in a choropleth reads it as zero, or as a gap in the boundaries β not as "unknown". missing_kwds with a hatch and a labelled legend entry is the difference between an honest absence and an accidental claim. The same reasoning applies to placeholder values masquerading as data, which is why they are worth checking for explicitly.
The deepest of the failures is the count-versus-rate one, because it is not about appearance at all. A choropleth encodes value as colour across an area, and human perception integrates colour over area whether or not you intend it. Mapping a count therefore produces a picture of where the denominator is large. The map is not badly rendered; it is showing a different variable from the one in its legend. Dividing by an appropriate denominator fixes more misleading choropleths than every other item in this article combined β the argument is developed in choropleth classification explained.
Edge cases or notes
scheme=needsmapclassifyinstalled, or GeoPandas raises.vmin/vmaxare ignored whenschemeis set. Classification and continuous normalisation are alternatives, not layers._rreverses any colormap.YlOrRd_rmakes high values pale.missing_kwdsalso acceptshatchandlabel, and both help β grey alone can read as another class.- Integer columns with nulls become floats. Pandas' nullable
Int64keeps them integers if that matters for the legend. TwoSlopeNormkeeps zero neutral with asymmetric tails, but colour distance then means different amounts on each side.- Categorical data needs
categorical=Trueand a qualitative palette such astab10; a sequential ramp implies an order that is not there. - Colour-blind readers cannot separate red from green.
viridis,cividisand ColorBrewer's safe ramps can. - Very small polygons are invisible whatever the colour. Consider a cartogram or proportional symbols.
legend_kwds={"fmt": "{:,.0f}"}removes50000.00000from a classified legend.
Internal links
- Choropleth classification explained β the scheme decision in depth
- How to create a choropleth map in Python with GeoPandas β building one correctly
- How to handle missing and null values in spatial datasets β what
missing_kwdsis for - How to clean and normalise attribute columns in a GeoDataFrame β fixing a non-numeric column
- Choosing a map projection for display β the other way a map misleads
- How to add legends, labels and a scale bar to a GeoPandas map β making the classes readable
- Why is my GeoPandas plot blank or empty? β when nothing is drawn at all
- How to find and remove spatial outliers in a point dataset β dealing with the value flattening your ramp
FAQ
Why is my whole map one colour?
Either no classification scheme was passed and an outlier is flattening the continuous ramp, or equal_interval on skewed data has put nearly everything in the first class. Check the class counts.
Why are my colours in a random order?
The column is not numeric, so GeoPandas is treating it as categorical. Convert with pd.to_numeric(..., errors='coerce') and check how many values failed to parse.
Why are there holes in my map?
Features with null values are not drawn. Pass missing_kwds={"color": "#e2e8f0", "hatch": "///", "label": "no data"} so absence is visible and labelled.
Why is the neutral colour of my diverging map not at zero?
Matplotlib centres the ramp on the midpoint of the data range, not on zero. Pass symmetric vmin=-lim, vmax=lim, or use TwoSlopeNorm(vcenter=0).
My map just shows where the cities are.
You are mapping a count. A choropleth colours areas, so a count shows the denominator β usually population. Map a rate instead.
vmin and vmax are having no effect.
They are ignored when a scheme is set, because classification replaces continuous normalisation. Use one or the other.
Which colormap should I use?
Sequential (YlOrRd, Blues, viridis) for magnitude, diverging (RdBu, PiYG) for deviation from a centre, qualitative (tab10) for categories. Prefer colour-blind-safe ramps for anything published.