How to Resample a Gridded Time Series to Monthly or Annual Values

On this page

Problem statement

Turning 6-hourly or daily grids into monthly or annual values is one line in xarray — da.resample(time="MS").mean() — and it always returns something. It returns a value for a month with three days of data, a label for a "year" built from six months, and an annual mean that treats February like July. None of those raise an error, and all of them change the numbers.

Measured on five years of NCEP/NCAR Reanalysis 1 6-hourly surface air temperature, 2020–2024 (7,308 time steps on a 73 × 144 grid, 307 MB in memory):

  • Resampling to monthly means took 55.4 ms; to daily, 165.4 ms; to annual, 112.1 ms.
  • pandas 3 rejects the old aliases. "M" raised ValueError: 'M' is no longer supported for offsets. Please use 'ME' instead., and "Y" and "A" failed the same way.
  • A half year still produced an annual value. Six months of 2024 resampled to one "2024" mean of 14.528 °C; the full year was 14.877 °C.
  • Averaging 12 monthly means without weighting by days moved annual values by up to 0.175 °C in a single cell; weighting by days matched the direct annual mean to within 0.0002 °C.

Quick answer

import xarray as xr

files = [f"air.sig995.{year}.nc" for year in range(2020, 2025)]
air = xr.concat([xr.open_dataset(f)["air"] for f in files], dim="time") - 273.15

monthly = air.resample(time="MS").mean()                 # labelled by the first of each month
samples = air.time.resample(time="MS").count()           # how many 6-hourly values went into each
print(dict(monthly.sizes), int(samples.min()), int(samples.max()))
{'time': 60, 'lat': 73, 'lon': 144} 112 124

February 2021 to 2023 held 112 six-hourly values, 31-day months 124.

Pick the alias for the label you want ("MS" or "ME", "YS" or "YE"), count the samples in every period, and mask the periods that are incomplete before using them.

pandas frequency aliases for resampling gridded time series and the labels they produce. aliases, periods and labelsaliasperiodfirst labelperiodsMScalendar month2020-01-0160MEcalendar month2020-01-3160YScalendar year2020-01-015YEcalendar year2020-12-315QS-DECDJF, MAM, JJA, SON2019-12-0121M, Y, AValueError--NCEP 6-hourly air temperature 2020-2024; pandas 3 spatialworkflow.io
The alias sets both the period and its label; the old single-letter forms no longer work.

Check the time axis, then choose frequency, label and statistic

1. Check the time axis first

Resampling assumes you know what is in the series. Print the first and last timestamps and the spacing: here 2020-01-01 00:00 to 2024-12-31 18:00, every 6 hours, 7,308 steps. Irregular spacing, duplicates or gaps change what each period contains.

2. Choose the frequency and the label

The alias decides both the length of each period and the timestamp it is labelled with:

alias    period                 first label    periods
MS       calendar month          2020-01-01       60
ME       calendar month          2020-01-31       60
YS       calendar year           2020-01-01        5
YE       calendar year           2020-12-31        5
QS-DEC   DJF, MAM, JJA, SON      2019-12-01       21

"MS" and "ME" hold identical values with different labels; pick one and use it everywhere, because joining a month-start series to a month-end series matches nothing. QS-DEC gives meteorological seasons — the first period starts in December 2019 and contains only January and February 2020.

3. Choose the statistic from the variable

Temperature averages; precipitation sums; extremes take the maximum or minimum. The statistic should match the variable's cell_methods and the question. A daily maximum from 6-hourly samples is only the largest of four values: across the globe it averaged 1.62 °C above the daily mean.

4. Resample, or coarsen when the spacing is regular

On the loaded 307 MB array:

operation                    time      result
resample(time="D").mean()   165.4 ms   1,827 days
resample(time="MS").mean()   55.4 ms   60 months
resample(time="YS").mean()  112.1 ms   5 years
coarsen(time=4).mean()      195.9 ms   1,827 days
groupby("time.year").mean() 162.1 ms   5 years

coarsen(time=4) gave exactly the same daily means as resample(time="D"), because every day had exactly four samples. coarsen counts steps, not dates, so it silently mixes days when a step is missing; resample does not.

5. Weight by days when combining monthly values

An annual mean from 12 monthly means gives February the same weight as July. Measured against annual means computed directly from the 6-hourly data, the unweighted version differed by up to 0.175 °C in a cell and by up to 0.0101 °C in the global mean. Weighting each month by its number of days reduced the difference to at most 0.00011 °C (Example 2).

6. Flag incomplete periods

resample returns a value for any period with at least one sample. A series cut on 19 December 2024 had 76 December samples instead of 124, and its December mean differed from the full month's by up to 5.82 °C in a cell. Six months of 2024 resampled to a single "2024" label with a mean of 14.528 °C, against 14.877 °C for the whole year. Count samples per period and mask those below what the period should hold (Example 1).

7. Decide how many missing steps are acceptable

Gaps inside a period bias it towards the part that remains. With 10% of time steps removed at random, monthly global means moved by at most 0.032 °C but single cells by up to 1.12 °C (Example 4); one missing week in January 2022 moved a cell's monthly mean by 2.86 °C. A threshold such as "at least 90% of expected samples" is a choice to state, not a default.

8. Do not upsample by leaving gaps

Resampling monthly data to daily with asfreq produces a daily axis of 1,797 steps of which 96.7% are NaN. Interpolating fills them with invented values. If you need daily data, use a daily source.

What an incomplete final period does to a resampled mean, the sample count and a single cell. complete and incomplete periodscompleteincomplete2024 global mean14.877 °C, 12 months✕14.528 °C, 6 monthsDecember 2024 samples124✕76December cell difference—✕up to 5.82 °CBoth incomplete periods were labelled exactly like complete ones spatialworkflow.io
resample labels a partial period exactly as it labels a complete one.

Monthly and annual values from six-hourly temperature

The data used on this page

The NCEP/NCAR Reanalysis 1 6-hourly near-surface air temperature (air.sig995.<year>.nc, 2.5° grid) for 2020–2024, from NOAA PSL. PSL drops long downloads, so the setup resumes and retries with curl.

import os
import subprocess

for year in range(2020, 2025):
    name = f"air.sig995.{year}.nc"
    urls = [f"https://downloads.psl.noaa.gov/Datasets/ncep.reanalysis/surface/{name}",
            f"https://psl.noaa.gov/thredds/fileServer/Datasets/ncep.reanalysis/surface/{name}"]
    for url in urls * 5:
        if os.path.exists(name):
            break
        if subprocess.run(["curl", "-sf", "-C", "-", "--retry", "3", "-o", name + ".part", url]).returncode == 0:
            os.replace(name + ".part", name)
    if not os.path.exists(name):
        raise RuntimeError(f"could not download {name}")
print({f"air.sig995.{y}.nc": os.path.getsize(f"air.sig995.{y}.nc") for y in range(2020, 2025)})
{'air.sig995.2020.nc': 22123352, 'air.sig995.2021.nc': 22067585, 'air.sig995.2022.nc': 22041061, 'air.sig995.2023.nc': 22084338, 'air.sig995.2024.nc': 22174317}

Example 1 — resample and mask incomplete periods

import pandas as pd
import xarray as xr


def resample_complete(da, freq="MS", samples_per_day=4, min_fraction=1.0, how="mean"):
    """Resample along time and set periods with too few samples to NaN."""
    result = getattr(da.resample(time=freq), how)()
    counts = da.time.resample(time=freq).count()
    starts = result.indexes["time"]
    days = (starts + pd.tseries.frequencies.to_offset(freq) - starts).days
    expected = xr.DataArray(days.to_numpy() * samples_per_day, coords={"time": starts})
    complete = counts >= min_fraction * expected
    incomplete = [str(t)[:10] for t in starts[~complete.to_numpy()]]
    print(f"{freq}: {int(complete.sum())} complete periods, incomplete {incomplete}")
    return result.where(complete)


cut = air.sel(time=slice(None, "2024-12-19"))
monthly = resample_complete(cut, "MS")
seasons = resample_complete(cut, "QS-DEC")
MS: 59 complete periods, incomplete ['2024-12-01']
QS-DEC: 19 complete periods, incomplete ['2019-12-01', '2024-12-01']

It works for start-anchored aliases (MS, YS, QS-DEC), where adding one period to a label gives the start of the next.

Example 2 — annual means from monthly means, weighted by days

import numpy as np


def annual_from_monthly(monthly):
    days = monthly.time.dt.days_in_month
    weights = days.groupby("time.year") / days.groupby("time.year").sum()
    return (monthly * weights).groupby("time.year").sum(min_count=1)


def global_mean(da):
    return da.weighted(np.cos(np.deg2rad(da.lat))).mean(("lat", "lon"))


monthly = air.resample(time="MS").mean()
direct = air.groupby("time.year").mean()
unweighted = monthly.groupby("time.year").mean()
weighted = annual_from_monthly(monthly)
for year in direct.year.values:
    d, u, w = (x.sel(year=year) for x in (direct, unweighted, weighted))
    print(f"{year}: unweighted {float(global_mean(u - d)):+.4f} °C global, {float(abs(u - d).max()):.3f} max cell | "
          f"day-weighted max cell {float(abs(w - d).max()):.5f}")
2020: unweighted -0.0029 °C global, 0.086 max cell | day-weighted max cell 0.00007
2021: unweighted -0.0101 °C global, 0.145 max cell | day-weighted max cell 0.00006
2022: unweighted -0.0090 °C global, 0.151 max cell | day-weighted max cell 0.00007
2023: unweighted -0.0095 °C global, 0.175 max cell | day-weighted max cell 0.00011
2024: unweighted -0.0040 °C global, 0.072 max cell | day-weighted max cell 0.00009

Example 3 — daily extremes and seasons

daily_mean = air.resample(time="D").mean()
daily_max = air.resample(time="D").max()
daily_min = air.resample(time="D").min()
print(f"daily max above daily mean, global average: {float(global_mean(daily_max - daily_mean).mean()):.2f} °C")
print(f"daily range, global average: {float(global_mean(daily_max - daily_min).mean()):.2f} °C")

seasons = resample_complete(air, "QS-DEC")
djf = seasons.sel(time=seasons.time.dt.month == 12)
print(dict(djf.sizes), [str(t)[:7] for t in djf.time.values])
daily max above daily mean, global average: 1.62 °C
daily range, global average: 3.21 °C
QS-DEC: 19 complete periods, incomplete ['2019-12-01', '2024-12-01']
{'time': 6, 'lat': 73, 'lon': 144} ['2019-12', '2020-12', '2021-12', '2022-12', '2023-12', '2024-12']

The six winters include the two partial ones, labelled 2019-12 and 2024-12; resample_complete has already masked them, so only the four complete winters hold values.

With four samples a day, the maximum and minimum are the warmest and coldest of four instants, not the true extremes; use a source that stores daily maxima when extremes matter.

Example 4 — the other numbers on this page

import time

air = air.load()
t = air.time.to_index()
print(f"{t[0]} to {t[-1]}, spacing {set(t.to_series().diff().dropna())}, {len(t):,} steps, "
      f"{air.nbytes / 1e6:.0f} MB in memory")

for alias in ("MS", "ME", "YS", "YE", "QS-DEC"):
    r = air.resample(time=alias).mean()
    print(f"{alias:7} first label {str(r.time.values[0])[:10]}  {r.sizes['time']} periods")

for label, run in [('resample(time="D").mean()', lambda: air.resample(time="D").mean()),
                   ('resample(time="MS").mean()', lambda: air.resample(time="MS").mean()),
                   ('resample(time="YS").mean()', lambda: air.resample(time="YS").mean()),
                   ("coarsen(time=4).mean()", lambda: air.coarsen(time=4).mean()),
                   ('groupby("time.year").mean()', lambda: air.groupby("time.year").mean())]:
    t0 = time.perf_counter()
    out = run()
    print(f"{label:28} {(time.perf_counter() - t0) * 1000:6.1f} ms  {out.sizes[list(out.dims)[0]]:,}")
same = np.allclose(air.coarsen(time=4).mean().values, air.resample(time="D").mean().values)
print(f"coarsen(time=4) equals resample('D'): {same}; load average {os.getloadavg()[0]:.1f}")

cut = air.sel(time=slice(None, "2024-12-19"))
dec_cut = cut.sel(time="2024-12").mean("time")
dec_full = air.sel(time="2024-12").mean("time")
print(f"December 2024 samples {cut.sel(time='2024-12').sizes['time']} of {air.sel(time='2024-12').sizes['time']}; "
      f"worst cell difference {float(abs(dec_cut - dec_full).max()):.2f} °C")
half = air.sel(time=slice("2024-01", "2024-06")).resample(time="YS").mean()
print(f"'2024' from January–June {float(global_mean(half).item()):.3f} °C, full year "
      f"{float(global_mean(air.sel(time='2024').mean('time'))):.3f} °C")

rng = np.random.default_rng(0)
keep = np.sort(rng.choice(air.sizes["time"], int(air.sizes["time"] * 0.9), replace=False))
m_full = air.resample(time="MS").mean()
m_gappy = air.isel(time=keep).resample(time="MS").mean()
print(f"10% of steps removed: global monthly means move at most "
      f"{float(abs(global_mean(m_gappy) - global_mean(m_full)).max()):.3f} °C, single cells up to "
      f"{float(abs(m_gappy - m_full).max()):.2f} °C")
week = air.sel(time=~((air.time >= np.datetime64("2022-01-10")) & (air.time < np.datetime64("2022-01-17"))))
jan = week.sel(time="2022-01").mean("time") - air.sel(time="2022-01").mean("time")
print(f"one week missing in January 2022: a cell's monthly mean moves up to {float(abs(jan).max()):.2f} °C")

up = air.resample(time="MS").mean().resample(time="D").asfreq()
print(f"monthly to daily with asfreq: {up.sizes['time']:,} steps, "
      f"{float(up.isel(lat=0, lon=0).isnull().mean()):.1%} NaN")
2020-01-01 00:00:00 to 2024-12-31 18:00:00, spacing {Timedelta('0 days 06:00:00')}, 7,308 steps, 307 MB in memory
MS      first label 2020-01-01  60 periods
ME      first label 2020-01-31  60 periods
YS      first label 2020-01-01  5 periods
YE      first label 2020-12-31  5 periods
QS-DEC  first label 2019-12-01  21 periods
resample(time="D").mean()     165.4 ms  1,827
resample(time="MS").mean()     55.4 ms  60
resample(time="YS").mean()    112.1 ms  5
coarsen(time=4).mean()        195.9 ms  1,827
groupby("time.year").mean()   162.1 ms  5
coarsen(time=4) equals resample('D'): True; load average 0.4
December 2024 samples 76 of 124; worst cell difference 5.82 °C
'2024' from January–June 14.528 °C, full year 14.877 °C
10% of steps removed: global monthly means move at most 0.032 °C, single cells up to 1.12 °C
one week missing in January 2022: a cell's monthly mean moves up to 2.86 °C
monthly to daily with asfreq: 1,797 steps, 96.7% NaN

Why resampling always returns something

Why resample always returns something

resample groups timestamps into bins defined by the alias and applies the statistic to whatever falls in each bin. A bin with one value has a mean. That makes it robust and makes it quiet: completeness is not part of the operation, so it has to be checked beside it.

Why labels matter as much as values

A monthly mean labelled 2020-01-01 and one labelled 2020-01-31 describe the same month. xarray aligns on labels, so subtracting one from the other, or merging a month-start series with a month-end climatology, gives NaN everywhere without an error. Choose a convention once — start-of-period labels are the usual choice for climate data — and keep it.

Why the monthly-to-annual weighting is small globally and large locally

Months differ in length by at most three days, so the weighting changes an annual global mean only in the second decimal place. In a single cell with a strong seasonal cycle, giving February 1/12 of the weight instead of 29/366 shifts the annual value towards winter or summer, which is where the 0.175 °C came from.

Why coarsen can be wrong when resample is right

coarsen(time=4) takes blocks of four consecutive steps. If one 6-hourly step is missing, every later block straddles two days. resample(time="D") bins by calendar date regardless of how many steps a day has. Use coarsen only after confirming the spacing is perfectly regular.

Largest single-cell difference between annual means from unweighted monthly means and direct annual means, by year. largest cell difference from the direct annual mean2020 unweighted0.086 °C2021 unweighted0.145 °C2022 unweighted0.151 °C2023 unweighted0.175 °C2024 unweighted0.072 °Cday-weighted, worst year0.00011 °C00.2 °CMonthly means weighted by the number of days in each month spatialworkflow.io
Weighting months by their length removes the difference almost entirely.

Calendars, precipitation totals and partial periods

  • Non-standard calendars (noleap, 360_day) resample with cftime indexes; see fixing cftime and out-of-bounds datetime errors.
  • Precipitation rates versus totals need different statistics: a rate in mm/day averages; an accumulation per step sums.
  • Time zones are not part of most climate files; a "day" is a UTC day.
  • Dask arrays resample lazily; installing flox speeds up grouped reductions on large arrays.
  • The last period of a monthly file is often partial: the NCEP monthly file's 2026 annual label came from two months.
  • Rolling means smooth without changing the sampling; they are not a substitute for resampling.
  • Seasonal labels from QS-DEC fall in December; relabel if your tables expect the year of January.

FAQ

How do I resample daily data to monthly means in xarray?

Use da.resample(time="MS").mean() for month-start labels or "ME" for month-end labels. On 7,308 6-hourly NCEP steps it took 55.4 ms and returned 60 months.

Why does resample(time="M") raise an error?

pandas 3 removed the single-letter aliases. Use "ME" or "MS" for months and "YE" or "YS" for years; "M", "Y" and "A" raise ValueError.

Does resample check that each month is complete?

No. A period with any samples gets a value. Count samples per period with da.time.resample(time=freq).count() and mask periods below the expected number.

Should I weight monthly means by the number of days?

For annual means, yes. Without weighting, annual values differed from direct annual means by up to 0.175 °C in a cell; with day weights, by at most 0.00011 °C.

What is the difference between resample and coarsen?

resample bins by calendar period; coarsen groups a fixed number of consecutive steps. They agreed exactly on complete 6-hourly data, but coarsen misaligns days as soon as a step is missing.

How do I get meteorological seasons?

Resample with "QS-DEC", which groups December–February, March–May, June–August and September–November and labels each season by its first month.