Tracking ocean temperatures

How much have the oceans warmed since 1850? Work a global sea surface temperature record down to a single clear signal, then map where the warming actually happened.

The data

This example uses COBE-SST2, NOAA's monthly sea surface temperature record, which reaches back to 1850. It is served over THREDDS, so there is nothing to download:

python
import nctoolkit as nc

ds = nc.open_thredds("https://psl.noaa.gov/thredds/dodsC/Datasets/COBE2/sst.mon.mean.nc")

That is a global 1° grid with one field per month for over 170 years — far too much to look at directly. Everything below is a way of reducing it to something you can read.

Work in whole years

The record runs up to the middle of the current year, and an incomplete final year would drag the last annual mean upwards — the global mean sea surface temperature cycle peaks in the first half of the year, when the ocean-dominated southern hemisphere is in summer. Each example below starts by subsetting to whole years, which is why ds.subset(years=range(1850, 2026)) keeps appearing.

A first look

The obvious question is how much the ocean has warmed. spatial_mean() collapses each monthly field to a single global number, turning the whole dataset into one time series:

python
ds = nc.open_thredds("https://psl.noaa.gov/thredds/dodsC/Datasets/COBE2/sst.mon.mean.nc")
ds.subset(years=range(1850, 2026))
ds.spatial_mean()
ds.plot()

Global mean sea surface temperature for every month since 1850, showing a noisy series rising by about one degree

Show the plotting code
python
import matplotlib.pyplot as plt

# a little shared styling, to match the rest of this page
def style(ax):
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.spines["left"].set_color("#c9d6da")
    ax.spines["bottom"].set_color("#c9d6da")
    ax.tick_params(colors="#56717d", labelsize=9)
    ax.grid(axis="y", color="#e5eef0", linewidth=0.8)
    ax.set_axisbelow(True)

df = ds.to_dataframe().reset_index()

fig, ax = plt.subplots(figsize=(9, 4.2))
ax.plot(df.time, df.sst, color="#087f8c", linewidth=0.6)
style(ax)
ax.set_ylabel("Sea surface temperature (°C)")
ax.set_title("Global mean sea surface temperature, monthly", fontsize=12)

A rise of roughly a degree is already visible. But this is monthly data, so the seasonal cycle and year-to-year variability make it noisy.

plot() gives you an interactive chart to explore in a notebook. The static figures on this page were drawn with matplotlib from to_dataframe() instead — the code is under each one.

Smoothing the signal

Passing "year" to tmean() averages within each year instead of over the whole record, which removes the seasonal swing. rolling_mean() then smooths across years — here over a 20-year window:

python
ds = nc.open_thredds("https://psl.noaa.gov/thredds/dodsC/Datasets/COBE2/sst.mon.mean.nc")
ds.subset(years=range(1850, 2026))
ds.tmean(["year"])
ds.spatial_mean()
ds.rolling_mean(20)
ds.plot()

Annual mean global sea surface temperature with a 20-year rolling mean drawn over it

Show the plotting code
python
# the figure shows both steps, so build the annual mean and the rolling
# mean as two datasets
annual = nc.open_thredds("https://psl.noaa.gov/thredds/dodsC/Datasets/COBE2/sst.mon.mean.nc")
annual.subset(years=range(1850, 2026))
annual.tmean(["year"])
annual.spatial_mean()

rolling = annual.copy()
rolling.rolling_mean(20)

df_a = annual.to_dataframe().reset_index()
df_r = rolling.to_dataframe().reset_index()

fig, ax = plt.subplots(figsize=(9, 4.2))
ax.plot(df_a.time, df_a.sst, color="#9ecfd4", linewidth=1.2,
        label="Annual mean")
ax.plot(df_r.time, df_r.sst, color="#087f8c", linewidth=2.4,
        label="20-year rolling mean")
style(ax)
ax.set_ylabel("Sea surface temperature (°C)")
ax.set_title("Annual mean, smoothed with a 20-year rolling mean",
             fontsize=12)
ax.legend(frameon=False, fontsize=9)

The figure above shows both steps together: the annual mean in pale teal, and the 20-year rolling mean over it. The rolling mean only begins in 1869, once there is a full window behind it.

Warming as an anomaly

Absolute temperatures make the change hard to judge — the interesting quantity is how far the ocean has moved from where it started. annual_anomaly() does the whole job in one call, taking the first 20 years of the record as the baseline:

python
ds = nc.open_thredds("https://psl.noaa.gov/thredds/dodsC/Datasets/COBE2/sst.mon.mean.nc")
ds.subset(years=range(1850, 2026))
ds.annual_anomaly(baseline=[1850, 1869], window=20)
ds.spatial_mean()
ds.plot()

Global mean sea surface temperature anomaly relative to an 1850 to 1869 baseline, rising to about 0.7 degrees

Show the plotting code
python
df = ds.to_dataframe().reset_index()

fig, ax = plt.subplots(figsize=(9, 4.2))
ax.axhline(0, color="#c9d6da", linewidth=1)
ax.plot(df.time, df.sst, color="#d76b45", linewidth=2.4)
ax.fill_between(df.time, 0, df.sst, color="#d76b45", alpha=0.15)
style(ax)
ax.set_ylabel("Temperature anomaly (°C)")
ax.set_title("Warming since the 1850–1869 baseline", fontsize=12)

Now the story is plain. The ocean cooled slightly into the early 1900s, warmed through to the 1950s, held roughly flat for thirty years, and has risen steeply since about 1980 — ending some 0.7 °C above where it began.

Mapping the change

A global mean hides where the warming happened. panel_plot draws several datasets side by side, so the recent mean state and the change since 1900 can be compared directly. Subtracting one dataset from another gives the change field:

python
recent = nc.open_thredds("https://psl.noaa.gov/thredds/dodsC/Datasets/COBE2/sst.mon.mean.nc")
recent.subset(years=range(2000, 2020))
recent.tmean()

early = nc.open_thredds("https://psl.noaa.gov/thredds/dodsC/Datasets/COBE2/sst.mon.mean.nc")
early.subset(years=range(1900, 1920))
early.tmean()

change = recent.copy()
change.subtract(early)

nc.panel_plot(
    {"Mean SST, 2000-2019": recent,
     "Change since 1900-1919": change},
    ncol=2,
    shared_colourbar=False,
    colours=["RdYlBu_r", "RdBu_r"],
    limits=[[0, 30], [-1.5, 1.5]],
    legend=["Sea surface temperature (°C)", "Temperature change (°C)"],
    legend_position="bottom",
)

Two world maps side by side: mean sea surface temperature for 2000 to 2019, and the change since 1900 to 1919 showing warming almost everywhere

Each panel needs its own colour scale here — one is an absolute temperature in the tens of degrees, the other a change of a degree or so either side of zero — which is what shared_colourbar=False is for. With it set, colours, limits and legend each take a list with one entry per panel.

The warming is close to universal, but far from even. Two features stand out against it: the pale patch in the North Atlantic south of Greenland, the so-called warming hole associated with a slowing overturning circulation, and the Southern Ocean, where deep water rising to the surface has kept warming slower than elsewhere.

Comparing two datasets

copy() matters here. subtract() modifies a dataset in place, so subtracting early from recent directly would leave nothing to plot in the first panel. Copying first keeps recent intact.