Colour on Maps Explained: Sequential, Diverging and Qualitative

On this page

Problem statement

Choosing a colour scheme feels like taste and is mostly arithmetic. Three properties decide whether a map can be read, and all three are measurable:

  • Does the scheme match the data? Ordered data needs an ordered scheme; categories need an unordered one. Using jet for a rate map or viridis for land-cover classes is a type error, not a preference.
  • Is it perceptually uniform? Equal steps in the data should look like equal steps on the map. Measured in CIE L*, viridis steps by at most 2.94 lightness units between adjacent samples; jet steps by up to 9.51, and its lightness reverses direction three times.
  • Does it survive colour-blind vision and greyscale? The minimum perceptual separation in tab10 drops from Ξ”E 27.7 in normal vision to 7.2 under deuteranopia and 4.6 under protanopia.

Getting this wrong produces maps that invent features the data does not contain, and maps that some readers cannot decode at all.

Quick answer

Pick the family from the data, then pick the ramp from the family:

def scheme_for(values, *, has_meaningful_midpoint=False, ordered=True):
    """The family follows from the data. Only the ramp is a choice."""
    if not ordered:
        return "qualitative"          # land cover, ownership, geology
    if has_meaningful_midpoint:
        return "diverging"            # change, anomaly, above/below a target
    return "sequential"               # rate, density, count, elevation


RECOMMENDED = {
    "sequential":  ["viridis", "cividis", "YlGnBu", "Blues"],
    "diverging":   ["RdBu", "BrBG", "PuOr"],          # anchored at the midpoint
    "qualitative": ["Dark2", "Set2", "tab10"],        # check under CVD first
}

Every ramp in the sequential list has monotonic lightness β€” measured, not assumed. jet and rainbow do not, and that single property is why they misrepresent data.

Lightness behaviour of six colour ramps, measured in CIE L* over 32 samples. measured over 32 samples of each rampL* rangereversalslargest stepviridis14.9 β†’ 90.9βœ“02.94cividis13.9 β†’ 91.2βœ“03.04YlGnBu13.4 β†’ 99.1βœ“04.17Blues20.9 β†’ 98.4βœ“03.72rainbow40.1 β†’ 91.5!25.74jet12.9 β†’ 94.6βœ•39.51Each lightness reversal draws a contour the data does not contain spatialworkflow.io
Edge detection in human vision runs on lightness, so a reversal reads as a boundary.

Classify the data, then pick the ramp it needs

1. Classify the data before choosing a colour

Three questions, in order:

  1. Is the variable ordered? Rate, density, elevation, count β€” yes. Soil type, land cover, ownership β€” no.
  2. Does it have a meaningful midpoint? Change since last year, deviation from a target, positive versus negative β€” yes, and the midpoint must sit at the colour's neutral point.
  3. Is it a count or a rate? Counts on unequal areas are a population map in disguise; normalise before colouring.

The third question is not about colour, but it is the one that most often makes a correct colour scheme tell a wrong story.

2. For sequential data, insist on monotonic lightness

Measured across 32 samples of each ramp (Example 1):

ramp        L* range        monotonic   direction changes   largest step
viridis     14.9 β†’ 90.9     yes                    0             2.94
cividis     13.9 β†’ 91.2     yes                    0             3.04
YlGnBu      13.4 β†’ 99.1     yes                    0             4.17
Blues       20.9 β†’ 98.4     yes                    0             3.72
rainbow     40.1 β†’ 91.5     no                     2             5.74
jet         12.9 β†’ 94.6     no                     3             9.51

A ramp whose lightness reverses creates visual boundaries where the data is smooth: readers see the light band in the middle of jet as an edge, because lightness is what the visual system uses to find edges. Three reversals means three false contours on every map.

3. For diverging data, anchor the midpoint

A diverging ramp has a neutral centre and two directions. Its centre must sit at the data's meaningful midpoint β€” usually zero β€” or the map reads backwards for part of its range:

import matplotlib.colors as mc

norm = mc.TwoSlopeNorm(vmin=data.min(), vcenter=0, vmax=data.max())
gdf.plot(column="change", cmap="RdBu", norm=norm, ax=ax)

Without the explicit norm, matplotlib centres the ramp on the middle of the data range. If the data runs from βˆ’2 to +18, the neutral colour lands at +8 and every value between 0 and 8 is coloured as if it were a decrease.

4. For categories, check the palette under colour-blind vision

Qualitative palettes are the ones that fail hardest, because they rely on hue alone. The smallest Ξ”E between any two colours in each palette, as measured in Example 2:

palette   normal   deuteranopia   protanopia   tritanopia   pairs under Ξ”E 10 (deut.)
tab10      27.7        7.2            4.6          15.2         4 of 45
Set1       32.7       10.0            3.7          17.6         1 of 36
Set2       25.4        4.0            2.5           6.3         1 of 28
Dark2      31.6        5.8            2.1          13.4         2 of 28
Accent     35.2       15.9            2.7          25.2         0 of 28

A minimum Ξ”E of 2.1 means two of the eight Dark2 colours are effectively the same colour for a reader with protanopia. The fix is not always a different palette β€” it is fewer categories, or a second visual variable such as hatching or a label.

5. Choose a text colour per band, not per map

There is no single text colour that is legible across a full ramp. Measured against the nine steps of two common sequential ramps at the WCAG 4.5:1 threshold:

  • viridis β€” white text passes on 4 of 9 steps, black text on 5 of 9.
  • YlGnBu β€” white passes on 3 of 9, black on 6 of 9.

So labels drawn on top of a choropleth need either a halo, a switch based on the underlying lightness, or a position outside the polygon.

6. Limit the number of classes

Seven sequential classes is about the limit for reliable reading, and five is comfortable. Beyond that, adjacent classes become indistinguishable and the legend becomes a lookup table nobody uses.

For qualitative schemes the limit is lower still β€” around eight β€” and colour-blind readers see fewer distinct colours than that, as the Ξ”E table shows.

Minimum perceptual distance within qualitative palettes under simulated colour vision deficiency. minimum Ξ”E between palette memberstab10 normal27.7tab10 deuteranopia7.2tab10 protanopia4.6Dark2 protanopia2.1Accent protanopia2.7Accent normal35.2036Below Ξ”E 10 two classes are not reliably distinguishable; below 5 they are one colour spatialworkflow.io
Four of tab10’s forty-five pairs fall below Ξ”E 10 under deuteranopia.

Measuring ramps and an anchored diverging map

The data used on this page

The colour measurements need only matplotlib. The diverging map uses real data with a meaningful zero: the change in resident population of the UK's 361 local authority districts between the ONS mid-2020 and mid-2025 estimates (from NOMIS), on the ONS December 2024 boundaries.

import os

import geopandas as gpd
import matplotlib
import numpy as np
import pandas as pd
import requests

matplotlib.use("Agg")
ONS = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
       "Local_Authority_Districts_December_2024_Boundaries_UK_BGC/FeatureServer/0/query")
NOMIS = ("https://www.nomisweb.co.uk/api/v01/dataset/NM_2002_1.data.csv?geography=TYPE424&gender=0"
         "&c_age=200&measures=20100&select=date_name,geography_code,obs_value&date=")
if not os.path.exists("districts.gpkg"):
    reply = requests.get(ONS, timeout=120, params={
        "where": "1=1", "outFields": "LAD24CD,LAD24NM", "outSR": 27700, "f": "geojson"})
    reply.raise_for_status()
    boundaries = gpd.read_file(reply.text).rename(columns={"LAD24CD": "code", "LAD24NM": "name"})
    population = (pd.concat([pd.read_csv(NOMIS + year) for year in ("2020", "2025")])
                  .pivot(index="GEOGRAPHY_CODE", columns="DATE_NAME", values="OBS_VALUE"))
    change = ((population[2025] / population[2020] - 1) * 100).rename("change")
    (boundaries[["code", "name", "geometry"]].merge(change, left_on="code", right_index=True)
        .dropna(subset=["change"]).to_file("districts.gpkg"))
districts = gpd.read_file("districts.gpkg")
print(f"{len(districts)} districts; change {districts['change'].min():.2f}% to {districts['change'].max():.2f}%, "
      f"{(districts['change'] < 0).sum()} fell")
361 districts; change -3.45% to 87.62%, 24 fell

Example 1 β€” testing a ramp for perceptual uniformity

import numpy as np
import matplotlib


def srgb_to_linear(c):
    c = np.asarray(c, dtype=float)
    return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)


def to_lab(rgb):
    """sRGB (0–1) to CIE L*a*b* under D65."""
    rgb = np.atleast_2d(np.asarray(rgb, dtype=float))[:, :3]
    m = np.array([[0.4124, 0.3576, 0.1805],
                  [0.2126, 0.7152, 0.0722],
                  [0.0193, 0.1192, 0.9505]])
    xyz = srgb_to_linear(rgb) @ m.T / np.array([0.95047, 1.0, 1.08883])
    d = 6 / 29
    f = np.where(xyz > d ** 3, np.cbrt(xyz), xyz / (3 * d * d) + 4 / 29)
    return np.stack([116 * f[:, 1] - 16,
                     500 * (f[:, 0] - f[:, 1]),
                     200 * (f[:, 1] - f[:, 2])], axis=1)


def ramp_report(name, n=32):
    cmap = matplotlib.colormaps[name]
    lightness = to_lab(cmap(np.linspace(0, 1, n))[:, :3])[:, 0]
    steps = np.diff(lightness)
    reversals = int(np.sum(np.sign(steps[:-1]) != np.sign(steps[1:])))
    print(f"{name:10} L* {lightness.min():5.1f} β†’ {lightness.max():5.1f}   "
          f"{'monotonic' if reversals == 0 else f'{reversals} reversals':14} "
          f"largest step {np.abs(steps).max():5.2f}")
    return reversals == 0
for name in ["viridis", "cividis", "YlGnBu", "Blues", "rainbow", "jet"]:
    ramp_report(name)
viridis    L*  14.9 β†’  90.9   monotonic      largest step  2.94
cividis    L*  13.9 β†’  91.2   monotonic      largest step  3.04
YlGnBu     L*  13.4 β†’  99.1   monotonic      largest step  4.17
Blues      L*  20.9 β†’  98.4   monotonic      largest step  3.72
rainbow    L*  40.1 β†’  91.5   2 reversals    largest step  5.74
jet        L*  12.9 β†’  94.6   3 reversals    largest step  9.51

Run this on any ramp somebody proposes β€” including the corporate one. A ramp with reversals will draw contours that are not in the data. The count of direction changes is taken over 32 samples; sampling more finely finds the same reversals, because they are the ramp's shape rather than rounding.

Example 2 β€” simulating colour vision deficiency

import numpy as np

# Machado, Oliveira & Fernandes (2009) matrices at full severity
CVD = {
    "deuteranopia": np.array([[0.367322, 0.860646, -0.227968],
                              [0.280085, 0.672501,  0.047413],
                              [-0.011820, 0.042940, 0.968881]]),
    "protanopia":   np.array([[0.152286, 1.052583, -0.204868],
                              [0.114503, 0.786281,  0.099216],
                              [-0.003882, -0.048116, 1.051998]]),
    "tritanopia":   np.array([[1.255528, -0.076749, -0.178779],
                              [-0.078411, 0.930809,  0.147602],
                              [0.004733, 0.691367,  0.303900]]),
}


def simulate(rgb, kind):
    lin = srgb_to_linear(np.atleast_2d(rgb)[:, :3])
    out = np.clip(lin @ CVD[kind].T, 0, 1)
    return np.where(out <= 0.0031308, out * 12.92, 1.055 * out ** (1 / 2.4) - 0.055)


def palette_safety(colours, threshold=10.0):
    """Minimum perceptual distance between palette members, per vision type."""
    results = {}
    for kind in ["normal", *CVD]:
        seen = np.asarray(colours) if kind == "normal" else simulate(colours, kind)
        lab = to_lab(seen)
        d = np.linalg.norm(lab[:, None, :] - lab[None, :, :], axis=-1)
        iu = np.triu_indices(len(colours), 1)
        results[kind] = {"min_dE": round(float(d[iu].min()), 1),
                         "confusable_pairs": int((d[iu] < threshold).sum())}
    return results
print(f"{'palette':8} {'colours':>7}  " + "  ".join(f"{k:>12}" for k in ["normal", *CVD]) + "   pairs under Ξ”E 10 (deut.)")
for name in ["tab10", "Set1", "Set2", "Dark2", "Accent"]:
    colours = np.asarray(matplotlib.colormaps[name].colors)[:, :3]
    r = palette_safety(colours)
    pairs = len(colours) * (len(colours) - 1) // 2
    print(f"{name:8} {len(colours):>7}  " + "  ".join(f"{r[k]['min_dE']:>12}" for k in r)
          + f"   {r['deuteranopia']['confusable_pairs']} of {pairs}")
palette  colours        normal  deuteranopia    protanopia    tritanopia   pairs under Ξ”E 10 (deut.)
tab10         10          27.7           7.2           4.6          15.2   4 of 45
Set1           9          32.7          10.0           3.7          17.6   1 of 36
Set2           8          25.4           4.0           2.5           6.3   1 of 28
Dark2          8          31.6           5.8           2.1          13.4   2 of 28
Accent         8          35.2          15.9           2.7          25.2   0 of 28

The numbers are the smallest Ξ”E (CIE76) between any two colours of the palette as each kind of vision sees it. Under about 10, two colours side by side are hard to tell apart; under about 2.3, they are the same colour.

Example 3 β€” a diverging map that is anchored correctly

import matplotlib.colors as mc
import matplotlib.pyplot as plt


def diverging_map(gdf, column, ax=None, cmap="RdBu", centre=0.0, symmetric=True):
    """Anchor the neutral colour at `centre`, and optionally make the two
    limbs equal so the eye can compare magnitudes across the midpoint."""
    values = gdf[column].dropna()
    if symmetric:
        reach = max(abs(values.min() - centre), abs(values.max() - centre))
        vmin, vmax = centre - reach, centre + reach
    else:
        vmin, vmax = values.min(), values.max()

    norm = mc.TwoSlopeNorm(vmin=vmin, vcenter=centre, vmax=vmax)
    ax = ax or plt.subplots(figsize=(8, 6))[1]
    gdf.plot(column=column, cmap=cmap, norm=norm, ax=ax, legend=True,
             edgecolor="white", linewidth=0.4)
    ax.set_axis_off()
    print(f"{column}: {values.min():.2f} … {values.max():.2f}, "
          f"neutral at {centre}, limbs {'equal' if symmetric else 'unequal'}")
    return ax
import matplotlib.pyplot as plt

fig, (left, right) = plt.subplots(1, 2, figsize=(12, 7))
districts.plot(column="change", cmap="RdBu", ax=left, legend=True, edgecolor="white", linewidth=0.2)
left.set_axis_off()
left.set_title("default normalisation")
diverging_map(districts, "change", ax=right, symmetric=False)
right.set_title("anchored at zero")
fig.savefig("population-change.png", dpi=80)

values = districts["change"]
default_centre = (values.min() + values.max()) / 2
print(f"default neutral point: {default_centre:.1f}%; districts on the 'decrease' side: "
      f"{(values < default_centre).sum()} of {len(values)}, of which {(values < 0).sum()} actually fell")
change: -3.45 … 87.62, neutral at 0.0, limbs unequal
default neutral point: 42.1%; districts on the 'decrease' side: 360 of 361, of which 24 actually fell

This is the failure from step 3 on real data. One district β€” the City of London, whose estimate rose 87.6% β€” stretches the range, so the default puts the neutral white at +42% and colours nearly every district as a decrease. Anchored at zero, the handful that shrank are the only red ones. symmetric=False here, because with equal limbs a single outlier at +87.6% would squash the whole of the decrease side into pale pink.

Symmetric limbs matter when the reader will compare "how much up" with "how much down". Unequal limbs are legitimate when the extremes are wildly different, but then say so in the legend.

The UK's 361 districts, population change 2020-25: where the neutral colour lands and how many districts read as a decrease. population change 2020–25, 361 districtsnormalisationneutral colour atshown as a decreasedefault (data range)+42.1%360 districtsTwoSlopeNorm, vcenter=00%24 districts24 districts actually fell; one outlier moved the midpoint to +42% spatialworkflow.io
Measured in Example 3. The City of London’s +87.6% stretches the range on its own.

Example 4 β€” which text colour each ramp step can carry

def luminance(rgb):
    return srgb_to_linear(np.atleast_2d(rgb)[:, :3]) @ np.array([0.2126, 0.7152, 0.0722])


def contrast(l1, l2):
    hi, lo = np.maximum(l1, l2), np.minimum(l1, l2)
    return (hi + 0.05) / (lo + 0.05)


for name in ["viridis", "YlGnBu"]:
    steps = luminance(matplotlib.colormaps[name](np.linspace(0, 1, 9)))
    white, black = contrast(steps, 1.0), contrast(steps, 0.0)
    print(f"{name:8} white passes {int((white >= 4.5).sum())} of 9, black {int((black >= 4.5).sum())} of 9, "
          f"neither on {int(((white < 4.5) & (black < 4.5)).sum())}")
viridis  white passes 4 of 9, black 5 of 9, neither on 0
YlGnBu   white passes 3 of 9, black 6 of 9, neither on 0

Why lightness carries the data

Why non-monotonic ramps invent features

Edge detection in human vision is driven mostly by lightness. A ramp whose lightness rises, falls and rises again places light bands in the middle of a smooth data range, and the reader sees those bands as boundaries.

jet reverses direction three times with a largest step of 9.51 L*. On a smooth surface β€” elevation, temperature, a kriged prediction β€” that produces three visible contours that exist only in the colour map. Readers then interpret them as thresholds in the data, which is the failure mode that made jet notorious in the scientific literature.

Why perceptual uniformity matters more than beauty

A uniform ramp means a fixed data step looks the same size wherever it occurs. A non-uniform one exaggerates some parts of the range and flattens others, so the map's apparent variability depends on which part of the range a region falls in.

viridis and cividis were designed to be uniform and to degrade gracefully to greyscale and to colour-blind vision. That is why they are dull compared with jet β€” the dullness is the uniformity.

Why qualitative palettes fail under colour vision deficiency

A qualitative palette distinguishes categories by hue at similar lightness, which is precisely the signal that red-green colour vision deficiency removes. The measurements make the size of the loss concrete: tab10's worst pair moves from Ξ”E 27.7 to 7.2 under deuteranopia, and Dark2 falls to 2.1 under protanopia β€” well below the threshold at which two colours can be told apart at all.

Red-green colour vision deficiency, mostly the milder deuteranomaly and protanomaly, affects about one man in twelve of northern European descent. A categorical map with eight hues is, for that reader, a map with five or six.

Why counts and rates need different treatment before colour

A choropleth colours areas, and areas differ in population. Mapping raw counts therefore maps population, and the sequential ramp faithfully renders that.

No colour scheme fixes it. Normalise to a rate, a density or a proportion first β€” then the colour choice is about the variable you actually mean.

No single label colour is legible across a sequential colour ramp. nine ramp steps, one text colour, four failuresviridis, 9 stepsβœ•white passes 4.5:1 on 4 of 9βœ•black passes on 5 of 9βœ•no colour works on allβœ•halo, or label outsideYlGnBu, 9 stepsβœ•white passes on 3 of 9βœ•black passes on 6 of 9βœ•black is the better defaultβœ•still 3 steps unhandledSwitch per band, add a halo, or put the label outside the polygon spatialworkflow.io
Measured against the WCAG 4.5:1 threshold on nine sampled ramp steps.

Greyscale, explicit norms and class counts

  • Print in greyscale before you commit. A monotonic ramp survives; a hue-based one does not.
  • Anchor diverging norms explicitly. The default centres on the data range, not on zero.
  • TwoSlopeNorm requires vmin < vcenter < vmax β€” it raises if all the data is on one side.
  • Colour-blind-safe is not a binary property. Check the specific palette at the specific number of classes you are using.
  • Five to seven sequential classes is the practical limit; beyond that the legend does the work the map should.
  • Reserve one colour for "no data" and make it visibly outside the ramp β€” grey or hatched.
  • Corporate palettes are usually qualitative. They can carry categories; they cannot carry a rate.
  • Do not use a rainbow to look scientific. It was the default in 1990 and it is a known defect now.

FAQ

What is the difference between sequential, diverging and qualitative?

Sequential is for ordered data with no meaningful midpoint; diverging is for ordered data with one, such as change around zero; qualitative is for unordered categories.

Why should I avoid jet and rainbow ramps?

Their lightness is not monotonic. Measured, jet reverses direction three times with a largest step of 9.51 L*, which draws visible contours the data does not contain.

Is viridis always the right choice?

For sequential data it is a safe default β€” monotonic lightness from 14.9 to 90.9, colour-blind friendly, greyscale-safe. It is wrong for categories and wrong for diverging data.

How many colours can a qualitative palette have?

About eight in normal vision, and fewer for colour-blind readers: Dark2's closest pair falls to Ξ”E 2.1 under protanopia. Use fewer categories, or add hatching or labels.

Can I put labels on top of a choropleth?

Not with one text colour. White text passes 4.5:1 on only 4 of viridis's 9 steps and black on 5, so use a halo, switch per band, or place labels outside the polygon.

How do I centre a diverging colour map on zero?

Use TwoSlopeNorm(vmin=..., vcenter=0, vmax=...). Without it the neutral colour lands in the middle of the data range, which colours real decreases as increases.