# Diagnose and fix xarray time decoding to `cftime` / `CFTimeIndex`
Use this when `xr.open_dataset` gives you an `object`-dtype `time` coordinate
with a `CFTimeIndex` instead of `datetime64`, when you see
`SerializationWarning: Unable to decode time axis into full numpy.datetime64
objects, continuing using dummy cftime.datetime objects instead`, or when
datetime ops on `time` fail after opening a file. Classify *why* decoding fell
back to cftime first — the cause determines whether you can convert to
`datetime64`, and at which resolution.
## 1. Confirm what you have and why
Inspect the index; the repr tells you the calendar, and the min/max tell you
whether the dates fit in `datetime64[ns]` (roughly years 1678–2262):
```python
ds = xr.open_dataset("model.nc")
print(ds.indexes["time"]) # CFTimeIndex([...], calendar='360_day', ...)
print(ds.time.min().values, ds.time.max().values)
print(ds.time.encoding.get("calendar"), ds.time.encoding.get("units"))
```
The fallback to cftime happens for exactly three reasons:
1. **Non-standard calendar** — `360_day`, `noleap`/`365_day`, `julian`, etc.
(any calendar that is not standard/proleptic_gregorian). The calendar name
comes from the file's `calendar` attribute, surfaced in `encoding`.
2. **Dates outside the `datetime64[ns]` range** — e.g. paleoclimate runs with
years like 0181. The warning names this cause explicitly: `reason: dates out
of range`.
3. **Standard calendar with dates before 1582-10-15** — the Gregorian reform
date; xarray refuses to silently misrepresent these as proleptic Gregorian
`datetime64`.
## 2. Do not "fix" it by force-converting blindly
The common reflex is `ds.indexes['time'].to_datetimeindex()`. That method
exists, but:
- On a **non-standard calendar** it emits a `RuntimeWarning`:
`Converting a CFTimeIndex with dates from a non-standard calendar, ... to a
pandas.DatetimeIndex, which uses dates from the standard calendar. This may
lead to subtle errors in operations that depend on the length of time between
dates.` A 360-day calendar mapped onto a 365-day DatetimeIndex silently
corrupts any duration arithmetic. Only convert when you know the calendar is
standard.
- On **out-of-range dates** the conversion is impossible for `ns` resolution —
that is why the fallback happened.
- It also emits a `FutureWarning`: `to_datetimeindex` will default to
`'us'`-resolution instead of `'ns'`-resolution output in a future version.
Always pass `time_unit` explicitly:
```python
di = ds.indexes["time"].to_datetimeindex(time_unit="ns")
```
`CFTimeIndex` already supports most `DatetimeIndex` operations — `.sel` with
strings and slices, `.groupby` with the `.dt` accessor (e.g.
`ds.groupby("time.month")`), and resample-style workflows. If your downstream
code only does selection and grouping, the correct fix is often to change
nothing and keep the `CFTimeIndex`.
## 3. When the dates fit a coarser `datetime64` unit, decode with it
Since xarray 2025.01.2, time decoding resolution can be `"s"`, `"ms"`, `"us"`,
or `"ns"` (default `"ns"`). A file whose dates fall outside the `ns` bounds
but inside the much wider `s` bounds decodes cleanly to `datetime64[s]`:
```python
coder = xr.coders.CFDatetimeCoder(time_unit="s")
ds = xr.open_dataset("paleo.nc", decode_times=coder)
# or, on an already-opened dataset:
ds = xr.decode_cf(xr.open_dataset("paleo.nc", decode_times=False),
decode_times=coder)
```
Rule of thumb: pick the coarsest unit that still represents your temporal
precision (climate model output at daily/monthly steps is fine at `"s"`; only
sub-second data needs finer). This is the fix for reason 2 and reason 3 above,
whenever the calendar itself is standard.
## 4. When the calendar is genuinely non-standard, keep cftime
For `360_day`, `noleap`, `julian` calendars there is no `datetime64`
equivalent — the calendar is the data's semantics, not a decoding accident.
Keep the `CFTimeIndex` and adapt the surrounding code:
- Select with strings/slices exactly as with `DatetimeIndex`:
`ds.sel(time="2000-01")`, `ds.sel(time=slice("2000-01", "2001-01"))`.
- Use the `.dt` accessor for components: `ds["time.month"]` via
`ds.groupby("time.month")`, `.dt.year`, `.dt.dayofyear`, etc.
- Do **not** compare cftime objects with pandas `Timestamp`s or `datetime`s —
mixed comparisons raise `TypeError`. Build comparisons from
`cftime.datetime` objects of the matching calendar, or from strings inside
`.sel`.
- If a plotting or export library requires real datetimes and your analysis
tolerates the calendar approximation, convert explicitly with
`to_datetimeindex(time_unit=...)` and document the approximation — never let
it happen implicitly.
A useful diagnostic for "is this calendar real or a file bug": check whether
other files from the same model/source use the same calendar. A single file
claiming `360_day` among `standard` siblings is usually a mislabeled header —
fix the file's `calendar` attribute rather than your code.
## 5. Mixed files: normalize decoding before combining
If `open_mfdataset` fails with time-related `MergeError`s or produces an
`object`-dtype time axis, the files likely decoded inconsistently (some to
`datetime64`, some to `CFTimeIndex`). Decode uniformly via `preprocess`:
```python
coder = xr.coders.CFDatetimeCoder(time_unit="s")
ds = xr.open_mfdataset(
"run_*.nc",
combine="nested",
concat_dim="time",
decode_times=False, # decode manually, identically, per file
preprocess=lambda d: xr.decode_cf(d, decode_times=coder),
)
```
## 6. Checklist
1. `print(ds.indexes['time'])` — calendar? min/max vs the 1678–2262 ns bounds?
2. Cause: non-standard calendar → keep `CFTimeIndex`, adapt comparisons;
out-of-range or pre-1582 standard dates → `CFDatetimeCoder(time_unit='s')`
(or coarser) via `decode_times`;
in-range standard dates that still came back object-dtype → the file's
`calendar` attribute is wrong; fix the file.
3. Never bare-convert a non-standard calendar to `DatetimeIndex` — the
`RuntimeWarning` about corrupted durations is telling the truth.
4. Pass `time_unit` explicitly to `to_datetimeindex` (the default is changing
from `ns` to `us`).
5. Mixed-file time failures → `decode_times=False` + uniform `decode_cf` in
`preprocess`.