# whenever: type-safe datetimes for Python that get DST right

> whenever is an MIT-licensed Python datetime library with separate types for instants, zoned times and plain local times, DST-aware arithmetic, and a Rust extension that can be skipped for a pure Python install.

**ariebovenberg/whenever** — ⏰ Type-safe datetimes for Python that get DST right. Rust or pure Python, your choice.

- Repository: https://github.com/ariebovenberg/whenever
- Website: https://whenever.rtfd.io
- Stars: 2,407 · Forks: 39
- Language: Python
- License: MIT
- Published: 2026-09-28 · Updated: 2026-09-28 · Language: en
- Canonical page: https://hysenlabs.com/projects/ariebovenberg-whenever

## The two datetime bugs whenever was built to remove

The README opens with a concrete failure. Adding eight hours to a Paris bedtime of 22:00 on 2023-03-25 returns 06:00, when the wall clock should read 07:00 because an hour was skipped for DST. The README is explicit that this is not a bug in CPython but a design decision: DST is only considered when a calculation involves two timezones. The second problem is typing. A signature like `def schedule_meeting(at: datetime) -> None` gives a type checker no way to tell whether the caller should pass a naive or an aware value, so the mistake surfaces at runtime instead of in CI.

whenever answers both by splitting the concept of a datetime into distinct classes. Instant is a moment with no timezone or calendar attached. ZonedDateTime carries an offset and an IANA zone name. PlainDateTime is a local wall-clock value with no zone at all. Because they are separate types, mixing them is a type error rather than a silent bug, and arithmetic on a ZonedDateTime accounts for DST. The target reader is a Python developer who has already been burned by a timezone bug and wants the compiler and the type checker on their side.

## How the type split and the Rust extension fit together

The repository is a hybrid. `pysrc/` holds the Python source, `src/` holds the Rust crate, and `Cargo.toml` declares the extension module `whenever._whenever` as a cdylib built with pyo3-ffi. The release profile uses fat LTO, a single codegen unit and strip, which is what you would expect from a project that treats parse, normalize, compare and format performance as a feature rather than a detail.

`setup.py` decides at install time whether to build that extension. If the `WHENEVER_NO_BUILD_RUST_EXT` environment variable is set to any value, or if the interpreter is PyPy or GraalVM, the Rust build is skipped and the slower Python implementation is used. If the build is attempted and fails, the script prints a message explaining the same environment variable before re-raising the error. That is a real design choice: the fallback is a supported configuration, not an accident, and it means the package can be installed on platforms where a Rust toolchain is unavailable.

The Rust crate requires edition 2024 and rust-version 1.93, so building from source needs a recent toolchain. The Python side requires 3.10 or higher and depends on tzdata on Windows and tzlocal on platforms that are neither macOS nor Linux. Free-threading and per-interpreter GIL support are listed as beta in the project classifiers, which is consistent with the project's own note that it is holding off on 1.0 while the API, especially durations, settles.

## Installing whenever and a first DST-safe calculation

Install from PyPI. On a machine without a Rust toolchain, or on PyPy, set the environment variable first so the build step is skipped and the pure Python implementation is used instead.

```bash
pip install whenever
WHENEVER_NO_BUILD_RUST_EXT=1 pip install whenever
```

The README's quickstart imports three types and shows the conversion path between them. Instant.now() gives a moment in time; to_tz() attaches a zone; a PlainDateTime is converted explicitly with assume_tz() before any zone-aware arithmetic.

```python
from whenever import Instant, ZonedDateTime, PlainDateTime

now = Instant.now()
paris = now.to_tz("Europe/Paris")

party_invite = PlainDateTime("2023-10-28 22:00")
party_starts = party_invite.assume_tz("Europe/Amsterdam")
print(party_starts.add(hours=6))
```

The documentation shows the output of that last call as `ZonedDateTime("2023-10-29 03:00:00+01:00[Europe/Amsterdam]")`. The wall clock moves back rather than forward because Amsterdam leaves DST that night, and the offset changes from +02:00 to +01:00. If you instead call add() directly on the PlainDateTime, the README shows a NaiveArithmeticWarning explaining that adjusting a local time ignores DST. That warning is the library telling you to make the zone explicit. Run mypy over the snippet and the type checker will also reject any attempt to pass a PlainDateTime where a ZonedDateTime is expected.

## What the type system will not do for you

The types stop you from mixing categories, but they do not resolve ambiguity inside a category. A PlainDateTime of 2023-10-29 02:30 in Amsterdam is a wall-clock reading that occurs twice, and assume_tz() has to pick one. The README does not document a rollback or a disambiguation policy in the excerpt available; the FAQ and the API reference are the places to check before you rely on a particular offset for a repeated hour. Treat that as the boundary of the guarantee: whenever makes the ambiguity visible and forces an explicit conversion, but the choice of which instant you meant is still yours.

The second limitation is maturity. The project classifier reads "Development Status :: 4 - Beta", the README says 1.0 is being held back so the API can be finalized, and the most recent release is 0.11.0b1, a pre-release from 2026-09-27, with 0.10.5 as the last stable version. If your policy is to depend only on stable releases, you will be pinning 0.10.5 and watching the changelog. Durations are called out by the README as the area where feedback is still wanted, so code that leans heavily on duration arithmetic is the code most likely to need edits across a minor bump.

Third, this is not a drop-in replacement for datetime. Every boundary with a library that expects a stdlib datetime, and every ORM column or JSON serializer that emits one, needs a conversion. The SQLAlchemy support lives in a separate package, whenever-sqlalchemy, and Pydantic support is described as beta. If your stack is a thin script that formats a timestamp once, the conversion cost outweighs the benefit.

## whenever against Arrow and Pendulum

The README names Arrow and Pendulum directly and gives a comparison table: whenever is marked DST-safe and typed for aware versus naive, the standard library is marked fast but neither DST-safe nor typed, Arrow is marked neither DST-safe nor typed, and Pendulum is marked partially DST-safe, untyped and slow.

The difference in approach is structural rather than cosmetic. Arrow collapses everything into a single `arrow.Arrow` type, which the README argues makes it harder for a type checker to catch a mistake because there is only one type to check against. whenever does the opposite, splitting one concept into several types so that the checker has something to enforce. Pendulum keeps a datetime-like model and patches some DST behavior; the README points to a page documenting which pitfalls remain, and notes that the project has had two releases in four years. That cadence claim is the README's, not an independent measurement, and it is the kind of statement worth re-checking against Pendulum's own release history if it matters to your decision. What whenever offers that neither does is the install-time choice between a Rust extension and a pure Python implementation, which matters on PyPy and on platforms where compiling a crate is not an option.

## Maintenance, licence and the cost of upgrading

The repository is not archived and the last push was on 2026-09-28, so the project is being worked on now. The release history shows a steady trickle: 0.10.4 on 2026-08-03, 0.10.5 on 2026-08-11, and 0.11.0b1 on 2026-09-27. That is a healthy cadence for a pre-1.0 library and also the reason to pin. A `~=` constraint on 0.10 will keep you on the stable line while the 0.11 pre-release is tested, and the changelog is the file to read before widening it.

The licence is MIT, declared both in `pyproject.toml` and in `Cargo.toml` for the Rust crate. MIT is permissive: it allows commercial and closed-source use with the copyright notice retained. It says nothing about the tz database that ships as a dependency on Windows, which carries its own public-domain terms. This is not legal advice; if your organisation has a dependency-review process, the two licence files are `LICENSE` and the `license` key in each manifest.

Upgrade cost is concentrated in two places. The Rust extension means a source build needs rust-version 1.93 and edition 2024, so CI images that compile from source need a current toolchain; setting WHENEVER_NO_BUILD_RUST_EXT sidesteps that at the price of speed. The pre-1.0 API means minor versions can move. The Makefile's `check-docstrings` target exists because the Rust docstrings are generated from Python by `scripts/generate_docstrings.py`, which tells you the two implementations are kept in step deliberately rather than drifting.

## Conclusion

Adopt whenever when a wrong datetime is a production incident and you can pin a dependency: it installs with pip install whenever, and setting WHENEVER_NO_BUILD_RUST_EXT gives you the pure Python path on PyPy or GraalVM. Skip it if you need a frozen 1.0 API on a fixed release cadence, or if your codebase is built on datetime and you are not ready to convert at the boundaries. Before committing, run mypy against a small module that uses Instant and ZonedDateTime, and confirm that WHENEVER_NO_BUILD_RUST_EXT=1 pip install whenever still passes your test suite on the interpreter you deploy.

## FAQ

### Does whenever require a Rust toolchain to install?

No. setup.py skips the Rust extension when the WHENEVER_NO_BUILD_RUST_EXT environment variable is set to any value, or when the interpreter is PyPy or GraalVM, and falls back to the pure Python implementation. If the build is attempted and fails, the script prints that same variable as the workaround before raising the error.

### What Python versions does whenever support?

pyproject.toml sets requires-python to >=3.10 and lists classifiers for CPython 3.10 through 3.15 as well as PyPy. Free-threading and per-interpreter GIL support are listed as beta.

### Is whenever a drop-in replacement for Python's datetime?

No. It defines separate types such as Instant, ZonedDateTime and PlainDateTime, and the README frames the split as the mechanism that turns a naive/aware mix-up into a type error. Code that hands datetimes to libraries expecting the standard library type needs explicit conversion at those boundaries.

### How does whenever handle DST in arithmetic?

The README's quickstart shows adding six hours to a ZonedDateTime in Europe/Amsterdam across the night of 2023-10-29, and the documentation gives the result with the offset changed from +02:00 to +01:00. Adding to a PlainDateTime instead raises a NaiveArithmeticWarning because adjusting a local time ignores DST.

### Is whenever stable enough for production?

The project classifies itself as Development Status :: 4 - Beta, and the README states that 1.0 is being held back so the API can be finalized, with feedback still wanted especially on durations. The most recent release is the pre-release 0.11.0b1; 0.10.5 is the last stable version.

## Sources

- [ariebovenberg/whenever on GitHub](https://github.com/ariebovenberg/whenever)
- [License: MIT](https://github.com/ariebovenberg/whenever/blob/main/LICENSE)
- [Project website](https://whenever.rtfd.io)
- [README](https://github.com/ariebovenberg/whenever/blob/main/README.md)
- [Releases](https://github.com/ariebovenberg/whenever/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/ariebovenberg-whenever
