Election Map Colours Explained: Diverging Scales, Midpoints and Breaks

On this page

Problem statement

The colours on an election map are not decoration; they are the data. A diverging scheme has to put its neutral colour on the tie, or the map tells each side it did better or worse than it did. Class breaks decide whether the country looks like a sea of deep red or a patchwork. And the two party colours have to stay distinct for the roughly one in twelve men with red-green colour vision deficiency. Most election-map mistakes are colour mistakes made by a default setting.

This page uses the 2024 US county margins to measure how three common classification schemes change the picture, what an off-centre scale does, and why red and blue survive colour blindness where red and green do not.

Quick answer

Centre the diverging scale on zero, use breaks that are symmetric around it, and check how many areas each class holds:

breaks = [-1, -0.5, -0.3, -0.15, -0.05, 0, 0.05, 0.15, 0.3, 0.5, 1]                 # symmetric, zero is a break
cls = np.clip(np.searchsorted(breaks, x, side="right") - 1, 0, 9)
print(f"zero sits between classes {np.searchsorted(breaks, 0, side='right') - 2} and {np.searchsorted(breaks, 0, side='right') - 1} "
      f"of 0-9; counties per class: {np.bincount(cls, minlength=10).tolist()}")
blue_side = cls < 5
print(f"Republican-won counties drawn in a blue shade: {np.sum(blue_side & (x > 0))}; Democratic in red: {np.sum(~blue_side & (x < 0))}")
zero sits between classes 4 and 5 of 0-9; counties per class: [41, 87, 130, 123, 67, 90, 174, 423, 758, 1216]
Republican-won counties drawn in a blue shade: 0; Democratic in red: 0
Three county maps of the 2024 margin with ten red-white-blue classes. With breaks symmetric around zero the map is mostly deep red with blue cities. With quantile breaks large parts of the West, Florida and the Northeast that voted Republican turn blue. With equal intervals over the data range the map is mostly mid and pale red. Three county maps of the 2024 margin with ten red-white-blue classes. With breaks symmetric around zero the map is mostly deep red with blue cities. With quantile breaks large parts of the West, Florida and the Northeast that voted Republican turn blue. With equal intervals over the data range the map is mostly mid and pale red.
The same county margins under three classification schemes. Data: county results compiled by tonmcg; US Census Bureau boundaries.

Neutral on the tie, breaks around it, colour checks

1. Put the neutral colour on the tie

A diverging colour scheme has two arms and a pale centre. The centre must sit at a margin of zero. Matplotlib's default Normalize(vmin, vmax) stretches the scale between the data's minimum and maximum, which are not symmetric:

lo, hi = x.min(), x.max()
print(f"data range {lo:+.3f} to {hi:+.3f}; zero lands at {(0 - lo) / (hi - lo):.3f} of the scale instead of 0.500")
for norm_name, norm in (("Normalize(min, max)", Normalize(lo, hi)), ("TwoSlopeNorm(vcenter=0)", TwoSlopeNorm(0, lo, hi)),
                        ("Normalize(-m, +m)", Normalize(-max(-lo, hi), max(-lo, hi)))):
    print(f"{norm_name:24s}: a tied county gets position {float(norm(0)):.3f}")
data range -0.838 to +0.925; zero lands at 0.475 of the scale instead of 0.500
Normalize(min, max)     : a tied county gets position 0.475
TwoSlopeNorm(vcenter=0) : a tied county gets position 0.500
Normalize(-m, +m)       : a tied county gets position 0.500

TwoSlopeNorm or a symmetric Normalize puts a tied county on the neutral colour. With the data range, a tie is drawn very slightly blue here. The asymmetry is small because the 2024 extremes happen to be similar, but in other data the shift can be large.

2. Choose breaks that respect the midpoint

Classification changes the picture more than any other setting:

schemes = {"symmetric": breaks, "quantile": np.quantile(x, np.linspace(0, 1, 11)), "equal interval (data range)": np.linspace(lo, hi, 11)}
for name, b in schemes.items():
    k = np.clip(np.searchsorted(b, x, side="right") - 1, 0, 9)
    wrong = np.sum((k < 5) & (x > 0)) + np.sum((k >= 5) & (x < 0))
    print(f"{name:28s}: darkest-red class {np.mean(k == 9):5.1%}; counties on the wrong side of the colour scale {wrong:,}")
symmetric                   : darkest-red class 39.1%; counties on the wrong side of the colour scale 0
quantile                    : darkest-red class 10.0%; counties on the wrong side of the colour scale 1,106
equal interval (data range) : darkest-red class  4.8%; counties on the wrong side of the colour scale 77

Quantile breaks put a tenth of the counties in each class. That sounds fair, but because almost seven in eight counties voted Republican, the zero falls inside the second class. Every class from there up holds Republican counties, and the four blue-coloured classes above the first paint 1,106 counties in the wrong party's colour. Equal intervals over the data range do better but still misplace 77, because zero is not one of their breaks, and they give fewer than 5% of counties the darkest red. Breaks symmetric around zero are the only scheme here that keeps every county on the correct side.

Three classification schemes applied to the 2024 county margins. same data, three colouringsbreaksdarkest-red classwrong side of thescaledarkest red on mapsymmetric around 039.1%039%quantile10.0%1,10611%equal interval (datarange)4.8%776%3,109 counties, ten classes, red–white–blue spatialworkflow.io
Same data, three colourings.

3. Check the colours for colour-vision deficiency

About 8% of men of European ancestry have red-green colour vision deficiency. Simulate it and measure how different the two party colours still are:

for a, b in (("#c0392b", "#2166ac"), ("#c0392b", "#2ca25f")):
    d_norm = delta_e(hex_rgb(a), hex_rgb(b))
    d_deut = delta_e(simulate(a, DEUTAN), simulate(b, DEUTAN))
    d_prot = delta_e(simulate(a, PROTAN), simulate(b, PROTAN))
    print(f"{a} vs {b}: colour difference normal {d_norm:5.1f}, deuteranopia {d_deut:5.1f}, protanopia {d_prot:5.1f}")
#c0392b vs #2166ac: colour difference normal  95.8, deuteranopia  90.8, protanopia  69.8
#c0392b vs #2ca25f: colour difference normal 102.6, deuteranopia  23.2, protanopia  25.0

Red and blue stay far apart under both common deficiencies; red and green, almost as different as red and blue for normal vision, collapse to a fraction of that. The US convention of red and blue happens to be one of the safest pairings.

How much of the map each class colours

The data used on this page

2024 presidential results by county from tonmcg/US_County_Level_Election_Results_08-24; county boundaries from the US Census Bureau cartographic boundary files, 2023, 1:20 million. Colour-vision simulation uses the matrices of Machado, Oliveira and Fernandes (2009) at full severity; colour difference is CIE76 Ξ”E in CIELAB (D65).

from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests
from matplotlib.colors import Normalize, TwoSlopeNorm

Path("data").mkdir(exist_ok=True)
for name, url in {"2024_US_County_Level_Presidential_Results.csv": "https://raw.githubusercontent.com/tonmcg/US_County_Level_Election_Results_08-24/master/2024_US_County_Level_Presidential_Results.csv",
                  "cb_2023_us_county_20m.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip"}.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=300).content)
res = pd.read_csv("data/2024_US_County_Level_Presidential_Results.csv", dtype={"county_fips": str})
res["fips"] = np.where(res.state_name == "District of Columbia", "11001", res.county_fips)
res = res.groupby("fips")[["votes_gop", "votes_dem", "total_votes"]].sum()
c = gpd.read_file("data/cb_2023_us_county_20m.zip")
c = c[~c.STATEFP.isin(["02", "15", "60", "66", "69", "72", "78"])].join(res, on="GEOID", how="inner")
x = ((c.votes_gop - c.votes_dem) / c.total_votes).to_numpy()

DEUTAN = np.array([[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.011820, 0.042940, 0.968881]])
PROTAN = np.array([[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]])


def hex_rgb(h):
    return np.array([int(h[i:i + 2], 16) / 255 for i in (1, 3, 5)])


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


def to_srgb(c):
    c = np.clip(c, 0, 1)
    return np.where(c <= 0.0031308, 12.92 * c, 1.055 * c ** (1 / 2.4) - 0.055)


def simulate(h, matrix):
    return to_srgb(matrix @ to_linear(hex_rgb(h)))


def lab(rgb):
    xyz = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]) @ to_linear(rgb)
    t = xyz / np.array([0.95047, 1.0, 1.08883])
    f = np.where(t > 216 / 24389, np.cbrt(t), (24389 / 27 * t + 16) / 116)
    return np.array([116 * f[1] - 16, 500 * (f[0] - f[1]), 200 * (f[1] - f[2])])


def delta_e(a, b):
    return float(np.linalg.norm(lab(a) - lab(b)))


print(f"{len(x):,} county margins from {x.min():+.3f} to {x.max():+.3f}")
3,109 county margins from -0.838 to +0.925

Example 1: how much of the map each class colours

c5070 = c.to_crs(5070)
area = (c5070.area / c5070.area.sum()).to_numpy()
for name, b in schemes.items():
    cl = np.clip(np.searchsorted(b, x, side="right") - 1, 0, 9)
    print(f"{name:28s}: darkest red covers {area[cl == 9].sum():.0%} of the map, darkest blue {area[cl == 0].sum():.1%}")
symmetric                   : darkest red covers 39% of the map, darkest blue 0.6%
quantile                    : darkest red covers 11% of the map, darkest blue 9.8%
equal interval (data range) : darkest red covers 6% of the map, darkest blue 0.1%
Colour difference between party colours for normal vision and simulated colour-vision deficiency. do the colours survive?normaldeuteranopiaprotanopiared vs blue95.8βœ“90.8βœ“69.8red vs green102.6βœ•23.2βœ•25.0CIE76 Ξ”E; Machado et al. (2009) simulation at full severity spatialworkflow.io
Red and blue survive colour-vision deficiency; red and green do not.

Which side, and how strongly

Colour on a choropleth has two jobs: to say which side a place is on, and how strongly. A diverging scheme does both if its midpoint sits on the tie and its arms are symmetric, so equal distances from zero get equally strong colours on both sides. Classification then decides how many places land in each strength. Quantiles answer "where does this county rank?", which is a different question from "how lopsided was it?" and is wrong for diverging data. Breaks at meaningful margins (5, 15, 30, 50 points) answer the question readers ask.

Choosing a colour scheme for an election map. which kind of scheme?What is the variable?margin, swingDivergingcentred on 0share, turnoutSequentialsingle huewinner of manyCategoricalone colour per partyThen check breaks, midpoint and colour vision spatialworkflow.io
Choosing a colour scheme; then check breaks, midpoint and colour vision.

The colours themselves must stay distinguishable for every reader and in every medium. That means checking them under simulated colour-vision deficiency, in greyscale, and on the light and dark backgrounds the map will be shown on. Red and blue pass; red and green, the pairing many default "good versus bad" palettes use, do not.

Two-slope norms, continuous colour and purple

Colour mistakes on election maps. colour mistakesSymptomFixRepublican counties drawn blueno quantile breaks on marginsa tie is not neutralTwoSlopeNorm(vcenter=0)zero falls inside a classmake 0 a break, symmetric arounditparties look alike to somered/blue, not red/greenDefault Normalize put a tie at 0.475, not 0.5 spatialworkflow.io
Colour mistakes on election maps. The default normalisation put a tie at 0.475.
  • TwoSlopeNorm(vcenter=0) in matplotlib, or vmin=-m, vmax=m, keeps a tie on the neutral colour.
  • Unclassed (continuous) colour avoids break choices but makes exact values harder to read; add a legend with ticks.
  • Turnout and share maps are sequential, not diverging; use a single-hue scheme.
  • Third-party wins need a third colour outside the diverging scheme.
  • Purple maps (blending red and blue) show mixture but make the winner hard to read; label them clearly.
  • Party colours differ by country: in the UK, red is Labour and blue Conservative, the opposite of the US.

FAQ

What colour scheme should an election margin map use?

A diverging scheme centred on a tie (margin 0), with breaks symmetric around it.

Why does my election map look too red (or too blue)?

Either the classification (quantiles put the midpoint in the wrong place) or the colour normalisation (min–max scaling moves the neutral colour off zero).

Are red and blue colour-blind safe?

Largely yes: under simulated deuteranopia and protanopia they stay far apart, unlike red and green.

Should I use quantile breaks for election results?

Not for margins: with lopsided data, quantiles put most classes on one side of zero and colour some places with the wrong party's colour.