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())
Triage rows matching each wrong-looking choropleth to its cause and fix.
Seven causes. The distribution and the dtype identify most of them.
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

Panels contrasting a diverging ramp centred on the data midpoint with one centred on zero.
Without a symmetric range, the neutral colour lands wherever the data's midpoint happens to be.
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

Stack showing the three mappings a choropleth performs: value to number, number to class, class to colour.
Three mappings, each with its own failure. Only the last one is about colour.

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= needs mapclassify installed, or GeoPandas raises.
  • vmin/vmax are ignored when scheme is set. Classification and continuous normalisation are alternatives, not layers.
  • _r reverses any colormap. YlOrRd_r makes high values pale.
  • missing_kwds also accepts hatch and label, and both help β€” grey alone can read as another class.
  • Integer columns with nulls become floats. Pandas' nullable Int64 keeps them integers if that matters for the legend.
  • TwoSlopeNorm keeps zero neutral with asymmetric tails, but colour distance then means different amounts on each side.
  • Categorical data needs categorical=True and a qualitative palette such as tab10; a sequential ramp implies an order that is not there.
  • Colour-blind readers cannot separate red from green. viridis, cividis and ColorBrewer's safe ramps can.
  • Very small polygons are invisible whatever the colour. Consider a cartogram or proportional symbols.
  • legend_kwds={"fmt": "{:,.0f}"} removes 50000.00000 from a classified legend.

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.