FAQ

Does performance really matter for a datetime library?

Most of the time, datetime handling isn’t the main bottleneck in Python programs—but datetime logic is arithmetic-heavy and often applied in bulk, making it a classic case where faster code pays off. That’s why many core Python components are backed by optimized implementations, and why this library offers a Rust extension for speed alongside a pure-Python backend for portability.

Is free-threaded Python supported?

Yes, free-threaded Python is supported. However, this support is still in beta. Please report any issues you encounter when using whenever in a free-threaded Python environment.

Why does Instant exist?

Since you can also express a moment in time using ZonedDateTime, you might wonder why Instant exists. The reason it exists is precisely because it doesn’t include a time zone. By using Instant, you clearly express that you only care about when something happened, not about the local time.

Consider the difference in intent:

class ChatMessage:
    sent: Instant       # only the moment matters

class CalendarEvent:
    start: ZonedDateTime  # the local time matters too

In the first example, it’s clear that you only care about the moment a message was sent. In the second, you communicate that you also store the user’s local time. This intent is crucial for reasoning about the code, and extending it correctly (e.g. with migrations, API endpoints, etc).

Why doesn’t Instant have .year, .hour, etc.?

An instant represents a specific moment in time, independent of any calendar system or time zone. Although its debug representation uses UTC, that’s just a convenient way to display it—it doesn’t mean the instant is a UTC datetime.

>>> now = Instant.now()
Instant("2026-01-23 05:30:15Z")
>>> now.year
AttributeError: 'whenever.Instant' object has no attribute 'year'

If you need to access calendar fields, convert to a datetime type first:

>>> now.to_tz("Europe/Amsterdam").year
2026
>>> now.to_fixed_offset().hour  # only if you truly need UTC fields
5

The same reasoning applies to construction: from_utc() is the field constructor of Instant, named for the input it takes. There is no Instant(2020, 1, 1), because it would silently mean UTC, the one reading the other exact types make you state with offset= or tz=.

Why is there no PlainDateTime.now() or Time.now()?

A clock factory takes exactly what its type needs to localize the clock, and nothing it would discard: Instant.now() takes nothing, ZonedDateTime.now(tz) a time zone, OffsetDateTime.now(offset) an offset, and Date.today(tz) a time zone. A PlainDateTime.now(tz) would take a time zone only to throw it away. Spell the discarding out instead:

>>> ZonedDateTime.now("Europe/Amsterdam").to_plain()
PlainDateTime("2026-01-23 06:30:15")
>>> ZonedDateTime.now("Europe/Amsterdam").time()
Time("06:30:15")

Why does OffsetDateTime exist?

It represents the common interchange case where local fields and a numeric offset are available but regional time zone rules are not. See the complete OffsetDateTime guidance for why this type is necessary and how to avoid stale-offset arithmetic.

Why are there three delta types?

Date and time deltas have fundamentally different arithmetic rules depending on whether their units are exact or calendar-based. The three types make that distinction explicit: TimeDelta holds normalized exact durations, ItemizedDateDelta holds calendar components, and ItemizedDelta holds a mix of both (including durations preserved from interchange formats).

See Working with deltas for how to choose a type and why itemization matters. The complete API details are in the delta reference.

Why warnings instead of errors?

Operations that could introduce DST bugs—such as arithmetic on OffsetDateTime or PlainDateTime—emit a warning rather than raising an exception. This is a deliberate choice, because these operations aren’t always wrong (see Design philosophy).

The warning approach gives you four levels of control:

  1. Learn by default — if you’re scripting quickly, the warning appears in the console and teaches you about the pitfall. If you’re in a hurry, the library doesn’t get in your way and you can fix the issue later.

  2. Ban project-wide — in application code, convert warnings to errors with Python’s standard warnings filter:

    import warnings
    warnings.filterwarnings("error", category=whenever.PotentialDstBugWarning)
    
  3. Call-local escape — when the operation is intentional, silence it with a keyword argument (e.g. stale_offset_ok=True), documenting the decision in code.

  4. Fine-grained — configure at the module or function level, using Python’s existing warning infrastructure.

Why are conversions called to_* and assume_*?

When converting between types, whenever uses two naming conventions:

  • to_* methods convert between types that already carry enough information to determine the result unambiguously. For example, ZonedDateTime.to_instant() can compute the exact moment because the time zone is known.

  • assume_* methods convert from types that lack information. The developer must supply the missing piece (a time zone, an offset). For example, assume_tz() requires you to specify which time zone the plain datetime is in.

The assume_* naming is intentional: it signals that you’re making an assumption that the library can’t verify for you.

Why the name PlainDateTime?

This has been an oft-discussed topic. Several names were considered for the concept of a “datetime without a time zone”.

Each option had its pros and cons.

  • Why not NaiveDateTime? This name is already used in the standard library, which does give it recognition. However, “naive” is a decidedly negative term. While datetimes without a time zone can be used in a naive way by developers who don’t understand the implications, they are not inherently wrong to use.

  • Why not CivilDateTime? This is the most “technically correct” name, as it refers to the time as used in civilian life. This name is most notably used in Jiff (Rust) and Abseil (C++) libraries. While this niche name is a boon to these languages, Python tends to favor more common, non-jargon names: “dict” over “hashmap”, “list” over “array”, etc.

  • Why not LocalDateTime? This is the name that ISO 8601 gives to the concept, also making it a “technically correct” name. However, the term “local” has become overloaded in the Python world where it often refers to the system time zone.

While PlainDateTime is not perfect, it has the following advantages:

  • Javascript’s new Temporal API uses this name. There’s significant overlap between Python and Javascript developers, so this name is likely to be familiar as its popularity grows.

  • It’s a name that is easy to understand and remember, also for non-native speakers.

Common critiques of PlainDateTime are:

  • The name doesn’t convey any meaning in itself. This is also a strength. It is simply a date+time. Yes, it can be used to represent a local time, but it doesn’t have to be.

  • The name is defined by what it is not. Actually, it’s really common to name things in opposition to something else. Think of: “stainless steel”, “plain text”, or “serverless computing”.

Are leap seconds supported?

Leap seconds are not fully supported. Taking leap seconds into account is a complex and niche feature, which is not needed for the vast majority of applications. This decision is consistent with other modern libraries (e.g. NodaTime, Temporal) and standards (RFC 5545, the UNIX timestamp convention) which do not support leap seconds.

However, whenever does accept leap seconds during parsing, normalizing them to the previous second (59). This applies to ISO 8601, RFC 2822, and patterns.

Why no drop-in replacement for datetime?

Fixing the issues with the standard library requires a different API. Keeping the same API would mean that the same issues would remain. Also, inheriting from the standard library would result in brittle code: many popular libraries expect datetime exactly, and don’t work with subclasses.

Is it production-ready?

The core functionality is complete, and version 0.11 establishes the 1.0 API: unless significant issues arise, 1.0 differs from it only by removing the spellings 0.11 deprecates. Please try it out and report any issues!

Where do the benchmarks come from?

More information about the benchmarks can be found in the benchmarks directory of the repository.

Is it compatible with SQLAlchemy?

Yes! Have a look at whenever-sqlalchemy, a separate package that provides SQLAlchemy types and utilities for working with whenever.

How can I use the pure-Python backend?

whenever is implemented both in Rust and in pure Python. By default, the Rust extension is used, as it’s faster and more memory-efficient. But you can opt out of it if you prefer the pure-Python backend, which has a smaller disk footprint and works on all platforms.

Note

On PyPy and GraalVM, the pure-Python backend loads automatically. No need to configure anything.

A pure-Python wheel is published alongside the platform wheels, but pip prefers the platform wheel when one matches. To select the pure-Python backend, install from the source distribution with --no-binary and the build config setting rust-extension=skip.

Installing this way is different depending on your tool of choice:

Pip

# as a one-off command
pip install whenever --no-binary whenever --config-settings rust-extension=skip

# in requirements.txt
--no-binary whenever
whenever --config-settings rust-extension=skip

Poetry

# as a one-off command
poetry run pip install whenever --no-binary whenever --config-settings rust-extension=skip

# in poetry.toml (not pyproject.toml!), Poetry 2.1 or later
[installer]
no-binary = ["whenever"]

[installer.build-config-settings]
whenever = { rust-extension = "skip" }

uv

# in pyproject.toml
[tool.uv]
no-binary-package = ["whenever"]
config-settings-package = { whenever = { rust-extension = "skip" } }

See uv’s documentation for more information.

Without the setting, a source install builds the Rust extension when a Rust toolchain is present. Where a tool can’t pass config settings, the environment variable WHENEVER_NO_BUILD_RUST_EXT=1 has the same effect.

You can check if the Rust extension is being used by running:

python -c "import whenever; print(whenever._EXTENSION_LOADED)"

This attribute is a diagnostic, not part of the API.

Why isn’t the pure-Python backend a separate package?

Some projects publish their compiled version under a separate name, like cytoolz alongside toolz. With two packages, the dependency list decides which backend you get:

  • If whenever included the Rust extension, every library depending on it would impose it on all of its users.

  • If the Rust extension were a separate package, most users would never know to install it.

Whether the Rust extension is welcome depends on where the code runs: a no-binary policy, a small container image, or a platform without wheels. The person running the install knows this; the library author doesn’t. Installers already have settings for this kind of choice, so whenever uses those (see How can I use the pure-Python backend?).

The downside is that opting out takes two settings instead of a package name, and is easy to overlook. Other layouts, such as a whenever-py package that whenever depends on, were discussed in issue #158.

What about dateutil?

dateutil is more of an extension to datetime than a replacement, so it isn’t included in the comparison with Pendulum and Arrow.

That said, while dateutil certainly provides useful helpers (especially for parsing and arithmetic), it doesn’t address the most fundamental issues with the standard library: DST-safety and type-level distinction between naive and aware datetimes. These are issues that only a full replacement can solve.

Why not simply wrap Rust’s jiff library?

Jiff is a modern Rust datetime library with similar goals and inspiration as whenever. There are several reasons whenever doesn’t wrap it:

  1. Jiff didn’t exist when whenever was created. Wrapping it was only an option after most functionality was already implemented.

  2. Providing a pure-Python backend of whenever would require re-implementing jiff’s logic in Python and keeping them in sync.

  3. Jiff has a slightly different design philosophy, most notably de-emphasizing the difference between offset and zoned datetimes.

  4. Jiff can’t make use of Python’s bundled time zone database (tzdata) if present.

  5. Writing a Rust library with Python bindings primarily in mind allows for some optimizations.

If you’re interested in a straightforward wrapper around jiff, check out Ry.

Why aren’t all operators supported for all types?

Some operators may be conspicuously missing for certain types, even though they could be implemented. Whenever provides operators where their meaning is useful and can be documented clearly.

For example, dates and datetimes support applying itemized deltas with + and -:

>>> Date(2024, 1, 31) + ItemizedDateDelta(months=1)
Date("2024-02-29")

These operators use the same calendar clamping rules as add() and subtract(). As a result, adding and then subtracting the same delta is not always reversible. Itemized deltas also support + and - with each other; these perform component-wise composition, and warn when years or months are involved: the composed delta can land on a different day than its parts applied in turn.

Operators without a generally useful interpretation remain unavailable. For example, itemized deltas cannot be multiplied or divided.

The - operator between two datetimes always returns a TimeDelta—an exact elapsed duration where subtraction is unambiguous. On PlainDateTime the result is naive arithmetic, and the operator warns. If you need a difference in calendar units like years, months, or days, use the since() / until() methods instead:

>>> d1 = ZonedDateTime(2020, 1, 1, tz="Europe/Amsterdam")
>>> d2 = ZonedDateTime(2023, 6, 15, tz="Europe/Amsterdam")
>>> d2 - d1  # exact elapsed time
TimeDelta("PT30263h")
>>> d2.since(d1, in_units=["years", "months", "days"])  # calendar units
ItemizedDelta("P3y5m14d")

See Design philosophy for the full rationale.

Why can’t I subclass whenever classes?

whenever classes are marked final and aren’t designed for subclassing. This is for several reasons:

  1. Composition is a better way to extend the classes. Python’s dynamic features also make it easy to create something that behaves like a subclass.

  2. Properly supporting subclassing requires a lot of extra work, and adds subtle ways to misuse the API.

  3. Enabling subclassing would undo some performance optimizations.

Why does .now() have different precision on different OSes?

When you call a .now() method (such as Instant.now()), whenever delegates to the underlying operating system to get the current time. The precision of this time depends on what the OS and hardware provide:

  • Linux typically provides nanosecond precision (9 digits).

  • macOS typically provides microsecond precision (6 digits).

  • Windows has its own variable precision depending on the version and configuration.

This is a limitation of the operating systems themselves, not whenever. If this difference in precision causes issues in your tests or when comparing values across different systems, you can normalize the precision by calling .round("microsecond").

Which time zone database version is in use?

There’s no reliable way to find out. whenever reads the IANA time zone database installed on your system, which has no standard place to record its version. Some distributions include a +VERSION file or a version comment in tzdata.zi; others don’t.

If you need a specific version, pin the tzdata package and empty the time zone search path, so that every time zone loads from tzdata:

from whenever import reset_tzpath

reset_tzpath([])  # use the tzdata package only

The version is then tzdata.IANA_VERSION. Call reset_tzpath() at startup, before any time zone lookup: loaded time zones stay cached until you call clear_tzcache() (see Working with the time zone database).