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

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 70.1 ms; to daily, 177.9 ms; to annual, 159.8 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.

Table of pandas frequency aliases for resampling: MS and ME for months, YS and YE for years, QS-DEC for meteorological seasons, and the retired M, Y and A aliases that now raise errors.
The alias sets both the period and its label; the old single-letter forms no longer work.

Step-by-step solution

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()   177.9 ms   1,827 days
resample(time="MS").mean()   70.1 ms   60 months
resample(time="YS").mean()  159.8 ms   5 years
coarsen(time=4).mean()      221.2 ms   1,827 days
groupby("time.year").mean() 208.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.030 °C but single cells by up to 1.29 °C; 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.

Table comparing the 2024 global mean temperature from all twelve months with the value resample returned from January to June only, and a December mean from the full month with one from a series cut on 19 December.
resample labels a partial period exactly as it labels a complete one.

Code examples

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.

Explanation

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.

Bar chart of the largest single-cell difference between annual means from unweighted monthly means and direct annual means, by year, against day-weighted monthly means.
Weighting months by their length removes the difference almost entirely.

Edge cases or notes

  • 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 70.1 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.