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
jetfor a rate map orviridisfor 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*,
viridissteps by at most 2.94 lightness units between adjacent samples;jetsteps 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
tab10drops 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.
Step-by-step solution
1. Classify the data before choosing a colour
Three questions, in order:
- Is the variable ordered? Rate, density, elevation, count โ yes. Soil type, land cover, ownership โ no.
- 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.
- 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.
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.
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.
TwoSlopeNormrequiresvmin < 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.
Internal links
- Visual hierarchy explained: what a map reader sees first โ where colour sits in the hierarchy
- How to choose and test a colour ramp in Python โ the working code
- How to check a map for colour-blind readers โ simulation in practice
- Choropleth classification explained โ class breaks, the other half of the choice
- Accessible maps explained: contrast, text and alternatives โ contrast thresholds
- How to make a choropleth map in GeoPandas โ applying it
- Fixing choropleth colours that look wrong โ when the ramp is not the problem
- Fixing map colours that change between screen and print โ colour beyond the ramp
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.