Colour on Maps Explained: Sequential, Diverging and Qualitative

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.

Grid of six colour ramps with their lightness range, reversals and largest step.
Edge detection in human vision runs on lightness, so a reversal reads as a boundary.

Step-by-step solution

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:

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:

palette   normal   deuteranopia   protanopia   pairs under ฮ”E 10
tab10      27.7        7.2            4.6          4 of 45
Set1       32.7       10.0            ...          1 of 36
Dark2      ...         5.8            2.1          2 of 28
Accent     35.2       15.9            2.7          1 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.

Bar chart of minimum colour distance in qualitative palettes under normal and deficient vision.
Four of tab10โ€™s forty-five pairs fall below ฮ”E 10 under deuteranopia.

Code examples

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

Run this on any ramp somebody proposes โ€” including the corporate one. A ramp with reversals will draw contours that are not in the data.

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

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

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.

Explanation

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.

Deuteranomaly and protanomaly together affect roughly one in twelve men. 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.

Two panels showing how few ramp steps pass a contrast threshold for white and black text.
Measured against the WCAG 4.5:1 threshold on nine sampled ramp steps.

Edge cases or notes

  • 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.