How to Draw a Wind Arrow Map in Python

On this page

Problem statement

An arrow per grid point is the most direct way to draw a wind field: the arrow points where the air is going, and its length shows the speed. Drawn naively, it is also unreadable. The 0.25° GFS has 6,497 points in a view of the Gulf of Mexico and the south-east, so the arrows overlap into a texture; matplotlib's automatic scaling makes the length mean nothing a reader can decode; and without a reference arrow nobody knows what length is 10 m/s.

This page draws the GFS 10-metre wind around Hurricane Helene at 00 UTC on 27 September 2024 with matplotlib.pyplot.quiver, choosing the thinning, the scale and the key deliberately.

Quick answer

Thin the grid to a readable density, fix the scale in data units, and add a key:

step = 4                                                       # every 4th point: 1° apart on a 0.25° grid
fig, ax = plt.subplots(figsize=(8, 6.5))
ax.set_aspect(1 / np.cos(np.radians(27)))                      # degrees of longitude are shorter than degrees of latitude
speed = np.hypot(W.u10, W.v10)
ax.pcolormesh(LON, LAT, speed, cmap="YlGnBu", vmin=0, vmax=40, shading="nearest")
q = ax.quiver(LON[::step], LAT[::step], W.u10.values[::step, ::step], W.v10.values[::step, ::step],
              scale=400, scale_units="width", width=0.0025)     # 400 m/s spans the axes width
ax.quiverkey(q, 0.88, 1.03, 20, "20 m/s", labelpos="E")
fig.savefig("helene_arrows.png", dpi=150)
print(f"grid {W.u10.shape[0]} × {W.u10.shape[1]} = {W.u10.size:,} points; arrows drawn: {W.u10.values[::step, ::step].size:,}")
print(f"speed: median {float(speed.median()):.1f} m/s, strongest {float(speed.max()):.1f} m/s")
grid 73 × 89 = 6,497 points; arrows drawn: 437
speed: median 6.8 m/s, strongest 39.4 m/s
Map of the Gulf of Mexico and the south-eastern United States with GFS 10 m wind speed shaded and dark arrows every fourth grid point, one degree apart, drawn to a fixed scale with a 20 metre per second key: the arrows circle Helene's centre west of Florida, longest east of the eye, and turn to southerlies over the Caribbean. Map of the Gulf of Mexico and the south-eastern United States with GFS 10 m wind speed shaded and dark arrows every fourth grid point, one degree apart, drawn to a fixed scale with a 20 metre per second key: the arrows circle Helene's centre west of Florida, longest east of the eye, and turn to southerlies over the Caribbean.
A readable arrow map: thinned, scaled and keyed.

Choose density, fix the scale, keep north up

1. Choose the density

Arrows need room to be read: as a rule of thumb, an arrow's length at typical speeds should be shorter than the spacing between arrows:

for s in (1, 2, 4, 8):
    n = W.u10.values[::s, ::s].size
    print(f"every {s} point(s): {n:>6,} arrows, {0.25 * s:.2f}° apart")
every 1 point(s):  6,497 arrows, 0.25° apart
every 2 point(s):  1,665 arrows, 0.50° apart
every 4 point(s):    437 arrows, 1.00° apart
every 8 point(s):    120 arrows, 2.00° apart

2. Fix the scale

By default quiver picks a scale from the data, so two maps with different winds get arrows of the same length. With scale_units="width", scale=400 means 400 m/s is drawn as the full width of the axes, so the same wind is the same length on every map that uses it:

axes_width_deg = float(LON.max() - LON.min())
for v in (5, 20, float(speed.max())):
    print(f"{v:4.1f} m/s → {v / 400:.1%} of the width = {v / 400 * axes_width_deg:.2f}° of longitude")
 5.0 m/s → 1.2% of the width = 0.28° of longitude
20.0 m/s → 5.0% of the width = 1.10° of longitude
39.4 m/s → 9.8% of the width = 2.17° of longitude

At 1° spacing, 20 m/s is a little longer than the gap to the next arrow, and the strongest wind about twice that, which is crowded only in the eyewall.

3. Keep north up and east right

quiver draws an arrow from its u and v in screen space by default (angles="uv"). On a plain longitude-latitude plot, east is to the right and north is up, so that is correct even after set_aspect. On a projected map the axes are not east and north, and the components have to be rotated first:

print(f"aspect: 1° of longitude is drawn {np.cos(np.radians(27)):.2f} as long as 1° of latitude at 27°N")
print("quiver default angles:", plt.quiver.__doc__.split("angles : {'uv', 'xy'} or array-like, default: ")[1][:4])
aspect: 1° of longitude is drawn 0.89 as long as 1° of latitude at 27°N
quiver default angles: 'uv'
Four panels of the same wind field over the eastern Gulf with arrows every 1, 2, 4 and 8 grid points, 0.25, 0.5, 1 and 2 degrees apart: at full density the arrows merge into a dark texture and overlap in the hurricane, at 0.5 degrees the circulation is visible but crowded, at 1 degree each arrow is distinct, and at 2 degrees the eye region is reduced to a few arrows. Four panels of the same wind field over the eastern Gulf with arrows every 1, 2, 4 and 8 grid points, 0.25, 0.5, 1 and 2 degrees apart: at full density the arrows merge into a dark texture and overlap in the hurricane, at 0.5 degrees the circulation is visible but crowded, at 1 degree each arrow is distinct, and at 2 degrees the eye region is reduced to a few arrows.
Too many arrows make a texture; too few lose the storm.

Colouring arrows by speed

The data used on this page

The GFS 0.25° analysis for 00 UTC on 27 September 2024 from the NOAA GFS bucket on AWS, 10 m UGRD and VGRD, cut to 18–36°N and 90–68°W.

from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import requests
import xarray as xr

Path("data").mkdir(exist_ok=True)
GFS = "https://noaa-gfs-bdp-pds.s3.amazonaws.com/gfs.20240927/00/atmos/gfs.t00z.pgrb2.0p25.f000"
G = Path("data/gfs_helene.grib2")
if not G.exists():
    lines = requests.get(GFS + ".idx", timeout=60).text.splitlines()
    starts = [int(l.split(":")[1]) for l in lines]
    with open(G, "wb") as f:
        for i, l in enumerate(lines):
            if ":UGRD:10 m above ground:" in l or ":VGRD:10 m above ground:" in l:
                f.write(requests.get(GFS, headers={"Range": f"bytes={starts[i]}-{starts[i + 1] - 1}"}, timeout=120).content)
ds = xr.open_dataset(G, engine="cfgrib", decode_timedelta=False, backend_kwargs={
    "indexpath": "", "filter_by_keys": {"typeOfLevel": "heightAboveGround", "level": 10}}).load()
W = ds.sel(latitude=slice(36, 18), longitude=slice(270, 292))
LON, LAT = W.longitude.values - 360, W.latitude.values
print(f"{W.sizes['latitude']} × {W.sizes['longitude']} grid points, {LON.min()} to {LON.max()}°E, {LAT.min()} to {LAT.max()}°N")
73 × 89 grid points, -90.0 to -68.0°E, 18.0 to 36.0°N

Example 1: colour by speed instead of a background

Arrows can carry the speed in colour as well as length, which frees the background for something else, such as pressure:

fig, ax = plt.subplots(figsize=(8, 6.5))
ax.set_aspect(1 / np.cos(np.radians(27)))
s = (slice(None, None, step), slice(None, None, step))
q = ax.quiver(LON[::step], LAT[::step], W.u10.values[s], W.v10.values[s], speed.values[s],
              cmap="viridis", scale=400, scale_units="width", width=0.003)
fig.colorbar(q, label="10 m wind speed (m/s)")
fig.savefig("helene_arrows_coloured.png", dpi=150)
print(f"colour range: {float(speed.values[s].min()):.1f} to {float(speed.values[s].max()):.1f} m/s across the thinned arrows")
colour range: 0.4 to 34.2 m/s across the thinned arrows

The thinned arrows top out at 34.2 m/s. The strongest wind on the full grid, 39.4 m/s, lies between them, which is the cost of thinning by taking every fourth point.

Why wind maps show speed twice

Number of arrows on the GFS view of the Gulf and the south-east as the grid is thinned. arrows left after thinningevery point, 0.25°6,497step 2÷ 3.9every 2nd, 0.5°1,665step 4÷ 3.8every 4th, 1°437 — readablestep 8÷ 3.6every 8th, 2°120Thinning is for display only: the strongest wind, 39.4 m/s, falls between the 1° arrows spatialworkflow.io
Arrows left after thinning.

An arrow shows direction directly, but its length means nothing until it is read against the key, and long arrows crowd their neighbours. That is why wind maps often show speed twice, in arrow length and in a colour field beneath, and why the key matters: it is the only way to read a length as a number.

Thinning by taking every nth point is the simplest choice and is fine for a smooth field. Where the field has small, strong features, as near a hurricane's eye, it can skip the strongest winds altogether; averaging the components over each block before drawing keeps them. Thinning never changes the data: compute from the full grid, thin only for display.

Arrow heads, data units and the dateline

Drawing wind arrow maps: problems and fixes. arrow map trapsTrapInsteadan arrow at every pointthin: 437 arrows, not 6,497automatic arrow scalescale=400, scale_units="width"no reference arrowquiverkey: 20 m/sthinning before computingcompute on the full grid; thin todrawGFS 10 m wind around Helene, 00 UTC 27 September 2024 spatialworkflow.io
Arrow map traps.
  • Arrow head: very short arrows become dots; set minlength or mask calm points.
  • Data units vs screen: scale_units="xy" with angles="xy" ties arrows to data units, which distorts directions when the aspect is not 1.
  • Projected maps: rotate u and v to the map's x and y before drawing; on a Lambert or polar map north is not up.
  • Averaging for thinning: average u and v, never speeds and directions.
  • Pole and dateline: thin and plot regional cuts; global arrow maps crowd at high latitudes on a plate carrée grid.

FAQ

How do I plot wind arrows in Python?

Use matplotlib.pyplot.quiver(lon, lat, u, v) with a thinned grid, a fixed scale and a quiverkey showing the length of a known speed.

Why are my quiver arrows all the same length on every map?

quiver scales arrows from the data by default; set scale and scale_units explicitly so the same wind speed is the same length on every map.

How many wind arrows should a map have?

Few enough that arrows at typical speeds are shorter than the space between them; on a regional map, roughly one per degree.

Should I use arrows or wind barbs?

Arrows suit general audiences and show flow; barbs, read in knots by meteorologists, show speed more precisely.