Fixing Global SST Means That Ignore Cell Area

On this page

Problem statement

A global or basin-wide mean sea surface temperature computed with .mean() over latitude and longitude comes out several degrees too cold, and its trend is off. Every cell of a latitude-longitude grid gets the same weight, but cells near the poles cover a fraction of the area of cells near the equator, so the cold high latitudes are counted far more than their share of the ocean. The error is invisible in the code and plausible in the result.

This page computes the global mean of ERSST v5 with and without area weights, shows where the cells and the area are, measures the error in the mean, the anomalies and the trend, and checks the cosine weight against exact cell areas.

Quick answer

Pass cosine-of-latitude weights to xarray's weighted before averaging:

last = SST.isel(time=-1)
naive = float(last.mean())
w = np.cos(np.deg2rad(last.latitude))
right = float(last.weighted(w).mean())
print(f"{pd.Timestamp(last.time.values):%B %Y}: unweighted mean {naive:.2f} °C, area-weighted {right:.2f} °C, error {naive - right:+.2f} °C")
August 2026: unweighted mean 14.91 °C, area-weighted 19.16 °C, error -4.25 °C

Without weights the global ocean averages 14.91 °C in August 2026; with them, 19.16 °C. The unweighted mean is 4.25 °C too cold.

Two panels for ERSST in August 2026: on the left, the share of ocean cells in each 2 degree row against the share of ocean area, with cells far over-represented near the poles; on the right, the zonal mean sea surface temperature by latitude, near 29 degrees in the tropics and near minus 1.8 at high latitudes, with the unweighted global mean at 14.9 and the weighted mean at 19.2 degrees marked. Two panels for ERSST in August 2026: on the left, the share of ocean cells in each 2 degree row against the share of ocean area, with cells far over-represented near the poles; on the right, the zonal mean sea surface temperature by latitude, near 29 degrees in the tropics and near minus 1.8 at high latitudes, with the unweighted global mean at 14.9 and the weighted mean at 19.2 degrees marked.
Cells against area.

Cells against area, effects on anomalies and trends, exact areas

1. Cells against area

Count the ocean cells and their share of the ocean area in latitude bands:

ocean = last.notnull()
band = pd.cut(np.abs(ocean.latitude.values), [0, 30, 60, 90], labels=["0-30°", "30-60°", "60-90°"])
c = ocean.sum("longitude").to_series().groupby(band).sum()
a = (ocean * w).sum("longitude").to_series().groupby(band).sum()
print(pd.DataFrame({"share of cells": (c / c.sum()).map("{:.0%}".format), "share of area": (a / a.sum()).map("{:.0%}".format)}).to_string())
       share of cells share of area
0-30°             38%           52%
30-60°            37%           38%
60-90°            25%           10%

Cells poleward of 60° are a quarter of all ocean cells but a tenth of the ocean area; the tropics are 38% of cells and 52% of the area. An unweighted mean gives the polar seas two and a half times their share.

Compute monthly global means both ways for the whole record, turn them into anomalies against 1991–2020, and compare the trend:

def series(weighted):
    s = (SST.weighted(np.cos(np.deg2rad(SST.latitude))) if weighted else SST).mean(["latitude", "longitude"]).to_series()
    c = s["1991":"2020"].groupby(s["1991":"2020"].index.month).mean()
    return s - s.index.month.map(c).values
u, wtd = series(False), series(True)
x = np.arange(len(u["1982":"2025"]))
print(f"trend 1982-2025: unweighted {np.polyfit(x, u['1982':'2025'], 1)[0] * 120:+.3f}, weighted {np.polyfit(x, wtd['1982':'2025'], 1)[0] * 120:+.3f} °C per decade")
print(f"largest monthly anomaly difference: {float((u - wtd).abs().max()):.2f} °C")
trend 1982-2025: unweighted +0.125, weighted +0.136 °C per decade
largest monthly anomaly difference: 0.12 °C

The anomaly series differ by up to 0.12 °C in a month, and the trend since 1982 is 0.125 °C per decade unweighted against 0.136 weighted, because the over-weighted high-latitude cells changed at different rates from the tropics.

Share of ocean cells and of ocean area by latitude band on the 2° ERSST grid. share of ocean cells and of ocean area, by band0-30°, cells38%0-30°, area52%30-60°, cells37%30-60°, area38%60-90°, cells25%60-90°, area10%020%40%60%Unweighted, the polar quarter of cells counts 2.5 times its area spatialworkflow.io
Cells and area by band.

3. Cosine weights against exact cell areas

For a regular grid, a cell's area is proportional to the difference of the sines of its edge latitudes, and the cosine of its centre latitude is almost exactly proportional to that:

half = 1.0                                                                           # ERSST cells are 2° tall
exact = np.sin(np.deg2rad(last.latitude + half).clip(-90, 90)) - np.sin(np.deg2rad(last.latitude - half).clip(-90, 90))
exact = xr.DataArray(np.abs(exact.values), coords={"latitude": last.latitude})
print(f"mean with cos weights {right:.4f} °C, with exact areas {float(last.weighted(exact).mean()):.4f} °C")
r = (exact / w).values
print(f"exact / cos ratio across latitudes: {r.min():.5f} to {r.max():.5f}")
mean with cos weights 19.1599 °C, with exact areas 19.1599 °C
exact / cos ratio across latitudes: 0.03490 to 0.03490

Exact cell areas and cosine weights give the same mean to four decimals: on a regular grid the ratio between them is the same constant at every latitude, so the weights are proportional.

A regional mean has the same problem

The data used on this page

Monthly NOAA ERSST v5 on its 2° grid from January 1950 to August 2026, from NOAA CoastWatch's ERDDAP.

from pathlib import Path

import numpy as np
import pandas as pd
import requests
import xarray as xr

Path("data").mkdir(exist_ok=True)
p = Path("data/ersst_global_1950_202608.nc")
if not p.exists():
    q = "sst[(1950-01-01):1:(2026-08-01)][(0.0)][(-88):1:(88)][(-180):1:(178)]"
    p.write_bytes(requests.get(f"https://coastwatch.pfeg.noaa.gov/erddap/griddap/nceiErsstv5_LonPM180.nc?{q}", timeout=900).content)
SST = xr.open_dataset(p).sst.squeeze(drop=True)
print(f"{SST.sizes['time']} months; latitudes {float(SST.latitude.min())} to {float(SST.latitude.max())} every {float(SST.latitude[1] - SST.latitude[0])}°")
920 months; latitudes -88.0 to 88.0 every 2.0°

The grid has 89 rows from 88°S to 88°N, 2° apart.

Example 1: a regional mean has the same problem

The error is not only global. Compare the two means for the North Atlantic from the equator to 70°N:

na = last.sel(latitude=slice(0, 70), longitude=slice(-80, 0))
print(f"North Atlantic, {pd.Timestamp(last.time.values):%b %Y}: unweighted {float(na.mean()):.2f} °C, weighted {float(na.weighted(np.cos(np.deg2rad(na.latitude))).mean()):.2f} °C")
North Atlantic, Aug 2026: unweighted 21.48 °C, weighted 23.47 °C

For the North Atlantic between the equator and 70°N the unweighted mean is 2 °C too cold, 21.48 against 23.47 °C.

Global mean SST with and without area weights, ERSST v5 August 2026. the same field, two means.mean()every cell counts the sameAugust 2026: 14.91 °Ctrend 1982-2025: +0.125North Atlantic: 21.48 °C.weighted(cos lat).mean()cells weighted by areaAugust 2026: 19.16 °Ctrend 1982-2025: +0.136North Atlantic: 23.47 °CExact cell areas give the same mean to four decimals spatialworkflow.io
Two means of one field.

Equal angles, unequal areas

Chart of the global ocean sea surface temperature anomaly from 1950 to 2026 as 12-month running means, unweighted and area-weighted: the weighted line starts lower in the 1950s and ends higher in the 2020s, so its trend is steeper. Chart of the global ocean sea surface temperature anomaly from 1950 to 2026 as 12-month running means, unweighted and area-weighted: the weighted line starts lower in the 1950s and ends higher in the 2020s, so its trend is steeper.
Weighted and unweighted anomalies.

On a latitude-longitude grid every cell spans the same angles, but a degree of longitude covers 111 km at the equator and nothing at the poles. The area of a cell is proportional to the cosine of its latitude, so a cell at 60° is half the size of one at the equator and a cell at 80° about a sixth. An unweighted mean treats them as equal, which in the ocean gives the cold Southern Ocean and Arctic seas much more influence than their area, and pulls the mean down by several degrees.

Trends are affected less than levels, because the bias in level is roughly constant, but not zero: high latitudes warm at different rates from the tropics, so over-weighting them changes the slope. The fix is a weight per cell: the cosine of the centre latitude for a regular grid, which matches exact areas to within a fraction of a percent, or the actual cell area for any other grid. Land and ice masks need no special handling in xarray's weighted, which ignores missing values and normalises by the weights of the cells that remain.

Missing data, anomalies and curvilinear grids

Checks for any spatial mean on a latitude-longitude grid. before averaging a lat-lon gridweights passedcos(latitude) or cell areamissing cells handledweighted() renormalisesanomalies weighted toosame weightsregional means toonot only globalother gridsuse the area variableAn unweighted global mean was 4.25 °C too cold spatialworkflow.io
Before averaging a grid.
  • Weights and missing data: weighted(w).mean() normalises by the sum of weights over non-missing cells; a manual (x * w).sum() / w.sum() over all cells is wrong where cells are missing.
  • Means of anomalies: averaging anomaly fields needs the same weights as averaging temperatures.
  • Projected or curvilinear grids: ocean models on tripolar or rotated grids provide a cell-area variable (areacello in CMIP output); use it.
  • Polar cells: cells centred at ±88° are tiny; the clip in the exact-area formula keeps their edges at ±90°.

FAQ

Why is my global mean sea surface temperature too cold?

It was probably averaged without area weights, so small high-latitude cells count as much as large tropical ones; weight by the cosine of latitude.

How do I compute an area-weighted mean in xarray?

da.weighted(np.cos(np.deg2rad(da.latitude))).mean(["latitude", "longitude"]).

Is cosine of latitude the exact area weight?

For a regular latitude-longitude grid it is proportional to the exact cell area to within a fraction of a percent; use true cell areas on other grids.

Do I need weights for anomalies too?

Yes; any spatial mean over a latitude-longitude grid needs them, whatever the variable.