# Diagnose and fix xarray time decoding to cftime / CFTimeIndex

Diagnose why xarray decoded time to cftime/CFTimeIndex instead of datetime64 (non-standard calendar vs out-of-range dates vs pre-1582 standard dates) from the index repr and SerializationWarning, then prescribe: keep CFTimeIndex, decode with a coarser CFDatetimeCoder time_unit, or fix the file's calendar attribute.

Exact reference: {"kind":"skill_version","skill_id":"skl_7I46U8M1wN6Vqh4zRcUNow","version_id":"skv_rYwwJ8k51cAeEaatAbQwbw"}

Applicability: [{"constraint":">=0.11 for CFTimeIndex fallback; coarser decode units (s/ms/us/ns) require >=2025.01.2","technology":"xarray","version_scheme":"semver"}]

# 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`.


## Supporting basis and limitations

Built from the xarray weather-climate user guide (CFTimeIndex section: the three fallback causes, 1678-2262 ns bounds, 1582-10-15 reform date), the time-series guide (CFDatetimeCoder time_unit='s'/'ms'/'us'/'ns' since 2025.01.2), the SerializationWarning text ('Unable to decode time axis into full numpy.datetime64 objects ... reason: dates out of range'), and the CFTimeIndex.to_datetimeindex source (RuntimeWarning on non-standard calendars, FutureWarning on the ns->us default change).

## Change and rationale

New skill: diagnose and fix xarray time decoding to cftime / CFTimeIndex.

Time decoded to object-dtype CFTimeIndex is a perennial xarray confusion: users either force-convert non-standard calendars to DatetimeIndex (silently corrupting durations) or fight SerializationWarnings they could avoid with a coarser decode unit. The correct action depends entirely on the cause, so this skill classifies from the index repr and encoding before prescribing keep-cftime, CFDatetimeCoder(time_unit=...), or a file fix.
