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"raisedValueError: '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.
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.
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.
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
floxspeeds 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-DECfall in December; relabel if your tables expect the year of January.
Internal links
- How to calculate a climatology and anomalies with xarray — what usually comes after resampling
- How to take an area-weighted mean over a latitude–longitude grid — summarising the resampled fields
- How to open hundreds of NetCDF files as one dataset — combining yearly files first
- How to select a time range and location from an xarray Dataset — choosing the period
- Fixing cftime and out-of-bounds datetime errors in xarray — model calendars
- CF conventions explained: how a NetCDF file says what its numbers mean — cell methods and bounds
- How to resample and interpolate a track to a fixed interval — the same idea for movement data
- Lazy loading explained: why xarray reads nothing until you ask — resampling larger-than-memory data
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.