Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Open In Colab Open In Kaggle

This notebook has three parts. First matplotlib, building a figure piece by piece: the figure/axes model, every core plot type, how to style and annotate one, and a checklist to run a figure through before it goes on a slide. Then cartopy, putting a map on the page — not just one projection, but a choice of them, each suited to a different question. Last, xarray, which wraps arrays in named dimensions, coordinates, and metadata so that operations read in the language of the data. One running dataset threads through all three parts — a year of daily 2 m air temperature on a small latitude–longitude grid — and the notebook closes with a generated-code plotting bug that silently flips a map upside down.

1.4.1 The Figure and Axes Model

The figure is the highest level of organization in matplotlib: the sheet of paper. An axes is one plot on that sheet, with its own x- and y-axis, and everything is drawn into an axes, never into the figure directly. Understanding that split is what makes the rest of the library predictable, so build a figure by hand once before switching to the shorthand used everywhere else.

<Figure size 640x480 with 0 Axes>
<Figure size 1300x500 with 0 Axes>

fig.add_axes([left, bottom, width, height]) places one axes by hand. The four numbers are fractions of the figure, so [0, 0, 1, 1] fills it completely and [0, 0, 0.5, 1] takes the left half.

<Figure size 640x480 with 1 Axes>
<Figure size 640x480 with 1 Axes>

Because each axes is positioned independently, the same call can place a small panel inside or beside a large one — an inset map, or a zoom on part of a series.

<Figure size 640x480 with 2 Axes>

1.4.2 Subplots

Positioning every axes by hand gets tedious as soon as there are more than two. fig.subplots(nrows, ncols) lays out a regular grid and returns the axes as an array, indexed like any other numpy array.

<Figure size 640x480 with 6 Axes>
<Figure size 1200x600 with 6 Axes>
ndarray (2, 3)

There is a shorthand that creates the figure and its axes in one call, and it is the recommended way to start a figure:

<Figure size 640x480 with 1 Axes>

Keyword arguments to plt.subplots split in two: those for the figure itself (figsize) and those forwarded to every axes it creates, collected in a dictionary under subplot_kw. The same dictionary is what asks for map axes rather than ordinary ones, which is how cartopy plugs into matplotlib.

<Figure size 800x400 with 2 Axes>
(2,)

1.4.3 Drawing into Axes

Everything below draws into an axes by name: ax.plot(...), ax.set_xlabel(...). This is matplotlib’s object-oriented style, and it is the one to learn. The data for the next few sections is a synthetic year of daily weather at three stations at different elevations, standing in for the kind of record a national weather service publishes.

(366,) -8.276103012911829 17.749338483741457
<Figure size 640x480 with 1 Axes>

This does the same thing as

<Figure size 640x480 with 1 Axes>

The difference is that plt.plot draws into whichever axes matplotlib currently considers active, and leaves you to keep track of which one that is. With one plot per figure it makes no difference. With two it does:

<Figure size 800x400 with 2 Axes>

1.4.4 Labeling Plots

Those two panels are unreadable: nothing on them says what is plotted or in what unit. set_xlabel, set_ylabel, and set_title fix that, and plt.tight_layout() re-packs the panels so the new text does not overlap. Every axis in this book carries its physical quantity and its SI unit.

<Figure size 800x400 with 2 Axes>

1.4.5 Customizing Line Plots

One call to ax.plot can take several x/y pairs in sequence, and draws each as its own line in the next color of the default cycle.

<Figure size 640x480 with 1 Axes>

Nothing forces the independent variable onto the x-axis. Swapping the arguments plots it vertically instead — which is how an atmospheric profile is read, with altitude increasing upwards.

<Figure size 350x450 with 1 Axes>

A parametric plot puts one measured quantity on each axis, and the curve is traced out by a third that appears nowhere on the figure. Monthly mean humidity against monthly mean temperature is a standard example: because humidity lags temperature through the year, the twelve months do not fall on a line but trace a loop.

<Figure size 450x400 with 1 Axes>

Line styles

linestyle takes either a name or its shorthand: "solid"/"-", "dashed"/"--", "dotted"/":", "dashdot"/"-.". linewidth is in points.

<Figure size 1400x400 with 3 Axes>

Colors

A handful of single letters are shorthand for the commonest colors:

  • b: blue

  • g: green

  • r: red

  • c: cyan

  • m: magenta

  • y: yellow

  • k: black

  • w: white

<Figure size 640x480 with 1 Axes>

color also accepts a grayscale level as a string between "0" (black) and "1" (white), an RGB tuple of three floats in [0, 1], and a hex code as used in html.

<Figure size 1400x400 with 3 Axes>

Lines drawn without a color argument take the next entry from a default color cycle, which matplotlib keeps in its runtime configuration dictionary plt.rcParams.

Loading...

The cycle holds ten colors, and starts over at the eleventh line — so a figure with more than ten series needs something other than color to tell them apart.

<Figure size 800x500 with 1 Axes>

Markers

marker draws a symbol at each data point; matplotlib offers dozens of them. markersize, markerfacecolor, and markeredgecolor control how each symbol is drawn, independently of the line joining them.

<Figure size 1200x400 with 2 Axes>

Setting linestyle="none" leaves the markers and drops the line, which is what you want for data that were measured at discrete times rather than sampled from something continuous.

<Figure size 600x300 with 1 Axes>

Labels, ticks, and gridlines

set_xticks/set_yticks choose where the tick marks fall and set_xticklabels replaces their text, which is how a day-of-year axis becomes a month axis. Passing minor=True adds a second, finer set of ticks, and grid(which=...) draws gridlines at either set. Any label can contain mathtext between dollar signs — r"discharge (m$^3$ s$^{-1}$)" renders the exponents properly.

<Figure size 1000x500 with 1 Axes>

Axis limits

set_xlim/set_ylim crop what is shown without touching the data, which is how you zoom into one part of a record.

<Figure size 600x300 with 1 Axes>

Text annotations

ax.text(x, y, "...") places a label at a data coordinate. ax.annotate does the same but draws an arrow from the text to the point it refers to, which is how you call out a single event in a long record.

<Figure size 700x350 with 1 Axes>

1.4.6 Scatter Plots and Histograms

ax.scatter draws unconnected points, and takes two more arguments that ordinary line plots do not: c maps a third variable to color and s maps a fourth to point area. A scatter plot of two measured quantities against each other, colored by time, shows a relationship and its seasonal drift in one panel. ax.hist bins one variable instead, showing how its values are distributed.

<Figure size 1000x360 with 3 Axes>

1.4.7 Bar Plots

ax.bar compares values across categories rather than along a continuous axis, and ax.barh draws the same bars horizontally, which leaves room for long category names.

<Figure size 1000x350 with 2 Axes>

1.4.8 Two-Dimensional Fields

A gridded field — temperature over a region, pressure over an ocean basin — is a 2D array plus the coordinates of its grid. matplotlib offers three ways to draw one, and they differ in exactly one respect: what they do with those coordinates.

imshow

imshow treats the array as an image and draws it by array position, ignoring coordinates entirely. It is the fastest of the three, and the one that can silently mislead: its default origin="upper" puts row 0 at the top, which is right for a photograph and wrong for a field whose first row is its southernmost latitude. origin="lower" puts row 0 at the bottom.

(4, 6)
<Figure size 1100x340 with 4 Axes>

Both axes are labelled in index, not in degrees, because that is genuinely all imshow knows. The two panels show the same array, one of them upside down, and nothing in the figure says which.

pcolormesh

pcolormesh draws one filled quadrilateral per grid cell and takes the coordinates as arguments, so the axes come out in degrees rather than in index. The coordinates can be given as two 1D arrays or as the two 2D arrays that np.meshgrid builds from them; the result is identical.

(4, 6) (4, 6)
<Figure size 1100x360 with 4 Axes>

The coordinates mean two different things depending on the shading argument, which catches people out. Under the default shading="auto", the coordinates you pass are the centres of the cells, and there are as many of them as there are values. Under shading="flat" they are the cell corners, so each axis needs one more coordinate than it has values — pass a coordinate array of the same shape as the data and matplotlib refuses rather than quietly dropping a row and a column. The two panels below come out identical, which is the point: the same six-by-four grid of cells, described two different ways.

7 5
<Figure size 1100x360 with 2 Axes>

Taking coordinates rather than indices also means pcolormesh can draw a grid whose cells are not rectangles at all. Ocean and regional climate models routinely run on such grids — rotated, stretched, or curved to follow a coastline — and this is the only one of the three methods that handles them.

<Figure size 600x400 with 2 Axes>

contour and contourf

Contours need a smoother and finer field than the six-by-four temperature grid, so these examples use a synthetic mean sea-level pressure field over the same region: a low centred southwest of the domain, sampled every 0.1°. contour draws the isobars as lines and contourf fills between them. Both accept 1D or 2D coordinates, exactly like pcolormesh.

(36, 61) 995.0 1010.9574468085107
<Figure size 1100x360 with 2 Axes>

An integer third argument asks for approximately that many contour levels; a sequence of values asks for exactly those. ax.clabel writes the value onto each line, which is how a weather chart labels its isobars, and a colorbar does the same job for filled contours.

<Figure size 1100x400 with 3 Axes>
<Figure size 1100x400 with 4 Axes>

1.4.9 Vector Fields: quiver and streamplot

Wind and ocean currents are vector fields: two components, u (eastward) and v (northward), at every grid point. Under geostrophic balance the wind blows along the isobars rather than across them, counter-clockwise around a low in the northern hemisphere, so the flow below circles the pressure centre from the previous section.

quiver draws one arrow per grid point, which on a 36 × 61 grid would be 2196 overlapping arrows — hence the [::4, ::4] slice, a standard move for wind plots. streamplot traces continuous flow lines instead and stays legible at full resolution.

<Figure size 800x450 with 1 Axes>
<Figure size 800x450 with 2 Axes>

1.4.10 Saving Figures

fig.savefig picks its format from the file extension. Save to a vector format (svg, pdf) for anything that will be printed, projected, or zoomed into: the file stores the lines themselves, so they stay sharp at any size. png stores pixels, and is for quick previews and for figures that are mostly image data anyway.

<Figure size 600x300 with 1 Axes>
['temperature_timeseries.pdf', 'temperature_timeseries.png', 'temperature_timeseries.svg']

1.4.11 A Plot Nobody Can Read

Every method above can be called correctly and still produce a figure that tells an audience nothing. Here is a month of summer temperature at two of the stations, plotted with all of matplotlib’s defaults and none of its labels — the figure you get by stopping at the first one that appears.

<Figure size 350x220 with 1 Axes>

Every point in that figure is at its correct value, and it is still unusable. There is no unit on either axis, so the vertical swings could be 5 °C or 5 K or 5 %. The horizontal axis runs from 0 to 29 because it is plotting array positions, not the days 180 to 209 the data actually cover. Two lines are distinguished only by being red and green — the one pair that the roughly 8 % of men with red–green colour-vision deficiency cannot separate — and nothing says which station is which.

Then there is the vertical range, which fits itself to whatever the data happen to span. Here that is one month, so day-to-day summer weather fills the panel as dramatically as a full seasonal cycle would, and nothing on the figure says that these same two records swing through 25 °C over a year. At this size the tick labels are also a few pixels tall on a projector.

The same data, with each of those fixed:

<Figure size 750x400 with 1 Axes>

Seen against the whole year, the month that filled the first panel is a flat stretch in a 25 °C seasonal cycle. Nothing was recomputed to get from one figure to the other.

1.4.12 Best Practices for Figures in a Talk

A figure in a paper is read from 40 cm away by someone who can go back and re-read the caption. A figure in a talk is read from 10 m away by someone who gets one pass at it while you are talking over it. The second is the harder constraint, so run every figure through the list below before it goes on a slide.

1.4.13 Maps with cartopy

A projection turns a round Earth into a flat axes. cartopy adds that step to matplotlib: give the axes a projection, then add geographic layers on top of it. cfeature.LAND and cfeature.OCEAN fill land and ocean with real colour, and coastlines() draws the boundary between them — a genuine map, not a gradient of measured values.

<Figure size 800x400 with 1 Axes>

A tour of standard projections

Only projection= changes below — the same add_feature calls draw into whichever axes they are given. PlateCarree is the simplest (longitude and latitude plotted directly, unchanged); Mercator, built for 16th-century sea navigation, preserves angles but inflates area toward the poles — Greenland ends up looking larger than Africa, though Africa’s true area is about 14 times greater; Robinson and Orthographic are compromises, chosen for how a global view looks rather than for any one preserved property.

<Figure size 1000x600 with 4 Axes>

Equal Earth: an equal-area alternative

EqualEarth, published in 2018, keeps land areas in true relative proportion — no continent is inflated to look bigger than it really is. On 4 September 2026 the UN General Assembly adopted the “Correct the Map” resolution, recommending Equal Earth over Mercator for general-purpose world maps: 164 member states in favour, six abstaining, one against (the United States). The resolution does not bind any organisation to switch, but it marks Equal Earth as the closest thing to an international standard for a general-reference world map today.

<Figure size 800x400 with 1 Axes>

Spilhaus: centred on the ocean, not the land

Every projection so far centres the world on its land. Spilhaus, devised by Athelstan Spilhaus in 1942, does the opposite: centred on Antarctica, it slices the continents apart instead of the ocean, so the Pacific, Atlantic, Indian, and Southern Oceans appear as one unbroken body of water. No standard world map can show that a current can, in principle, carry water from any ocean basin to any other — Spilhaus makes it visible at a glance.

<Figure size 600x600 with 1 Axes>

A polar view

SouthPolarStereo centres the projection on the South Pole rather than the equator; set_extent([lon_min, lon_max, lat_min, lat_max], crs=...) then crops the view to a bounding box in a stated crs — here, everything south of 50° S.

<Figure size 500x500 with 1 Axes>

Choosing a projection

There is no single correct projection — only one that fits the question being asked.

  • Atmospheric science mostly reaches for PlateCarree: reanalysis and forecast model output is natively a longitude–latitude grid, so plotting it needs no reprojection. For anything centred on the poles — the polar vortex, the ozone hole, sea ice extent — a polar stereographic projection like the one above is standard instead.

  • Oceanography uses Spilhaus specifically to show that ocean basins are one connected system, which matters for circulation and heat transport that do not respect continental boundaries. Mercator persists for its original purpose, marine navigation, since a straight line on a Mercator map is a constant compass bearing.

  • Geography and general reference now points to EqualEarth, for the reason the UN resolution above gives: it does not exaggerate the size of any region relative to another.

The tools above cover every case here — only projection= and, where a bounding box is needed, set_extent change.

1.4.14 xarray: Arrays That Carry Their Labels

xarray attaches names and coordinates to numpy arrays. A DataArray is a single labelled array; a Dataset is a dict-like collection of DataArrays sharing coordinates. Operations then refer to dimensions by name (dim="time") instead of by axis number. The example below reuses field_celsius, the exact grid plotted earlier with pcolormesh, as the spatial pattern for a full year of daily fields.

DataArray dims: ('time', 'lat', 'lon') shape: (366, 4, 6)
<xarray.Dataset> Size: 73kB
Dimensions:  (time: 366, lat: 4, lon: 6)
Coordinates:
  * time     (time) datetime64[s] 3kB 2024-01-01 2024-01-02 ... 2024-12-31
  * lat      (lat) float64 32B 46.0 46.5 47.0 47.5
  * lon      (lon) float64 48B 6.5 7.0 7.5 8.0 8.5 9.0
Data variables:
    t2m      (time, lat, lon) float64 70kB -4.808 -2.57 -6.037 ... -7.164 -7.682
Dataset -> DataArray
dims: ('time', 'lat', 'lon')
shape: (366, 4, 6)
units: degC
isel(time=0) shape: (4, 6)
2024-07-15 spatial mean: 11.93 °C
nearest lat to 46.7: 46.5
domain_mean dims: ('time',) | clim_field dims: ('lat', 'lon')
anomaly dims: ('time', 'lat', 'lon') | mean anomaly: 0.0
monthly time steps: 12
months: [ 1  2  3  4  5  6  7  8  9 10 11 12]
warmest climatological month: 7

xarray’s .plot() reads coordinates, labels, and units straight from the metadata: a 1D array becomes a line, a 2D array a pcolormesh, both labelled automatically. The domain-mean line below echoes the very first plot in this notebook — the same shape of data, now derived by reducing a full spatiotemporal field instead of being handed to you ready-made.

<Figure size 1000x320 with 3 Axes>

xarray also reads and writes netCDF directly, preserving every coordinate and attribute through the round trip — the self-describing format .npy was not, back in 1.3.

['t2m'] | units: degC

When generated code lies: a flipped map

AI assistants reach for imshow to display a 2D array. Here, an assistant takes the time-mean of ds["t2m"] — the same xarray Dataset built and plotted throughout this notebook — but pulls out .values before plotting, dropping every coordinate xarray was carrying. By default imshow draws array row 0 at the top of the figure (origin='upper'). Our latitude coordinate is ascending (row 0 is the southernmost), so the map comes out upside down — north and south swapped — with no error and a perfectly plausible-looking picture.

<Figure size 500x300 with 2 Axes>
<Figure size 1000x320 with 4 Axes>

Summary

ConceptRule to remember
Figure and axesplt.subplots() covers the common case; fig.add_axes([l, b, w, h]) places one axes explicitly.
Draw by nameUse ax.plot and ax.set_xlabel, not plt.plot, which writes into whichever axes is current.
Several seriesThe default colour cycle runs out after ten — add linestyles or markers as well.
Two-dimensional fieldsimshow draws by array position, so state origin; pcolormesh and contour take the coordinates.
SavingVector formats (svg, pdf) stay sharp at any size.
ReadabilityA plot can be numerically correct and still unreadable: label the axes, avoid red against green, size text for the room.
Mapsprojection is the crs you draw in, transform= is the data’s own; no projection is universally correct.
xarrayA DataArray is one labelled array; a Dataset is a collection sharing coordinates.
Selecting.sel by label, .isel by position; reduce over named dims, not axis numbers.

Resources