🕗 Datetimes

This page gives an overview of whenever’s main features for working with datetimes. For more details, see the API reference.

Different types

In API design, if you’ve got two things that are even subtly different, it’s worth having them as separate types—because you’re representing the meaning of your data more accurately.

—Jon Skeet

While the standard library has a single datetime type, whenever provides five distinct types. Each is designed to communicate intent, prevent mistakes, and optimize performance. You probably won’t need all of them simultaneously in your project. Read on to find out which one is right for you.

from whenever import (
    UTCDateTime, OffsetDateTime, ZonedDateTime, LocalSystemDateTime, NaiveDateTime
)

Here’s a summary of how you can use them:

Feature

Aware

Naive

UTC

Offset

Zoned

Local

comparison

difference

add/subtract delta

unambiguous

to/from timestamp

now

to/from common ISO8601

to/from RFC3339/2822

UTCDateTime

Always UTC: simple, fast, and unambiguous. It’s great if you’re storing when something happened (or will happen) regardless of location.

>>> py311_livestream = UTCDateTime(2022, 10, 24, hour=17)
UTCDateTime(2022-10-24 17:00:00Z)

In most cases, you should use this class over the others. The other classes are most often useful at the boundaries of your application.

OffsetDateTime

A time with a fixed offset from UTC. This is great if you’re storing when something happened, including the local time.

>>> # Pycon was in Salt Lake City that year
>>> pycon23_start = OffsetDateTime(2023, 4, 21, hour=9, offset=-6)
OffsetDateTime(2023-04-21 09:00:00-06:00)

It’s less suitable for future events, because local UTC offsets often change (e.g. due to daylight saving time). For this reason, you cannot add or subtract time from an OffsetDateTime — the offset may have changed!

ZonedDateTime

This class accounts for the variable UTC offset of timezones, and is great for representing localized times in the past and future. Note that when the clock is set backwards, times occur twice. Use disambiguate to resolve these situations.

>>> changing_the_guard = ZonedDateTime(2024, 12, 8, hour=11, tz="Europe/London")
ZonedDateTime(2024-12-08 11:00:00+00:00[Europe/London])
>>> ZonedDateTime(2023, 10, 29, 1, 15, tz="Europe/London", disambiguate="later")
ZonedDateTime(2023-10-29 01:15:00+00:00[Europe/London])

LocalSystemDateTime

This is a datetime in the system local timezone. Unless you’re building a system that specifically runs on the user’s local machine (such as a CLI), you should avoid using this type.

>>> # assuming system timezone is America/New_York
>>> backup_performed = LocalSystemDateTime(2023, 12, 28, hour=2)
LocalSystemDateTime(2023-12-28 02:00:00-05:00)

NaiveDateTime

In dealing with real-world data, you’ll often encounter datetimes that don’t have timezone information. Use NaiveDateTime to represent these datetimes.

>>> invite_received = NaiveDateTime(2020, 3, 14, hour=15)
NaiveDateTime(2020-03-14 15:00:00)

Comparison and equality

All types support equality and comparison. However, NaiveDateTime instances are never equal or comparable to the aware types.

Aware types

For aware types (UTCDateTime, OffsetDateTime, ZonedDateTime, and LocalSystemDateTime), comparison and equality are based on whether they represent the same moment in time. This means that two datetimes with different values can be equal:

>>> # different ways of representing the same moment in time
>>> as_utc = UTCDateTime(2023, 12, 28, 11, 30)
>>> as_5hr_offset = OffsetDateTime(2023, 12, 28, 16, 30, offset=hours(5))
>>> as_8hr_offset = OffsetDateTime(2023, 12, 28, 19, 30, offset=hours(8))
>>> as_zoned = ZonedDateTime(2023, 12, 28, 6, 30, tz="America/New_York")
>>> # all equal
>>> as_utc == as_5hr_offset == as_8hr_offset == as_zoned
True
>>> # comparison
>>> as_zoned > OffsetDateTime(2023, 12, 28, 11, 30, offset=hours(5))
True

Note

Another way to think about this is that the equality operator compares the UTC equivalent of the datetimes. a == b is always equivalent to a.as_utc() == b.as_utc(), and a > b is always equivalent to a.as_utc() > b.as_utc(), and so on.

Note that if you want to compare for exact equality on the values (i.e. exactly the same year, month, day, hour, minute, etc.), you can use the exact_eq() method.

>>> d = OffsetDateTime(2023, 12, 28, 11, 30, offset=5)
>>> same = OffsetDateTime(2023, 12, 28, 11, 30, offset=5)
>>> same_moment = OffsetDateTime(2023, 12, 28, 12, 30, offset=6)
>>> d == same_moment
True
>>> d.exact_eq(same_moment)
False
>>> d.exact_eq(same)
True

Naive types

For NaiveDateTime, equality is simply based on whether the values are the same, since there is no concept of timezones or UTC offset:

>>> d = NaiveDateTime(2023, 12, 28, 11, 30)
>>> same = NaiveDateTime(2023, 12, 28, 11, 30)
>>> different = NaiveDateTime(2023, 12, 28, 11, 31)
>>> d == same
True
>>> d == different
False

See also

See the documentation of __eq__ (aware) and NaiveDateTime.__eq__ for more details.

Strict equality

Naive and aware types are never equal or comparable to each other. However, to comply with the Python data model, the equality operator won’t prevent you from using == to compare them. To prevent these mix-ups, use mypy’s --strict-equality flag.

>>> # These are never equal, but Python won't stop you from comparing them.
>>> # Mypy will catch this mix-up if you use enable --strict-equality flag.
>>> UTCDateTime(2023, 12, 28) == NaiveDateTime(2023, 12, 28)
False

Why not raise a TypeError?

It may seem like the equality operator should raise a TypeError in these cases, but this would result in surprising behavior when using values as dictionary keys.

Unfortunately, mypy’s --strict-equality is very strict, forcing you to match aware types exactly.

d = UTCDateTime(2023, 12, 28, 10)

# mypy: ✅
d == UTCDateTime(2023, 12, 28, 10)

# mypy: ❌ (too strict, this should be allowed)
d == OffsetDateTime(2023, 12, 28, 11, offset=1)

To work around this, you can either convert explicitly:

d == OffsetDateTime(2023, 12, 28, 11, offset=1).as_utc()

Or annotate with a union:

d: OffsetDateTime | UTCDateTime == OffsetDateTime(2023, 12, 28, 11, offset=1)

Conversion

Between aware types

You can convert between aware datetimes with the as_utc(), as_offset(), as_zoned(), and as_local() methods. These methods return a new instance of the appropriate type, representing the same moment in time. This means the results will always compare equal to the original datetime.

>>> d = ZonedDateTime(2023, 12, 28, 11, 30, tz="Europe/Amsterdam")
>>> d.as_utc()  # same moment in UTC
UTCDateTime(2023-12-28 10:30:00Z)
>>> d.as_offset(5)  # same moment with a +5:00 offset
OffsetDateTime(2023-12-28 15:30:00+05:00)
>>> d.as_zoned("America/New_York")  # same moment in New York
ZonedDateTime(2023-12-28 05:30:00-05:00[America/New_York])
>>> d.as_local()  # same moment in the system timezone (e.g. Europe/Paris)
LocalSystemDateTime(2023-12-28 11:30:00+01:00)
>>> d.as_offset(4) == d
True  # always the same moment in time

To and from naïve

Conversion to naïve types is always easy: calling naive() simply strips away any timezone information:

>>> d = ZonedDateTime(2023, 12, 28, 11, 30, tz="Europe/Amsterdam")
>>> n = d.naive()
NaiveDateTime(2023-12-28 11:30:00)

You can convert from naïve types with the assume_utc(), assume_offset(), and assume_zoned(), and assume_local() methods.

>>> n = NaiveDateTime(2023, 12, 28, 11, 30)
>>> n.assume_utc()
UTCDateTime(2023-12-28 11:30:00Z)
>>> n.assume_zoned("Europe/Amsterdam")
ZonedDateTime(2023-12-28 11:30:00+01:00[Europe/Amsterdam])

Note

The seemingly inconsistent naming of the assume_* methods is intentional. The assume_* methods emphasize that the conversion is not self-evident, but based on assumptions of the developer.

Arithmetic

Datetimes support varous arithmetic operations with addition and subtraction.

Difference between times

You can subtract two datetime instances to get a TimeDelta representing the duration between them. Aware types can be mixed with each other, but naive types cannot be mixed with aware types:

>>> # difference between moments in time
>>> UTCDateTime(2023, 12, 28, 11, 30) - ZonedDateTime(2023, 12, 28, tz="Europe/Amsterdam")
TimeDelta(12:30:00)
>>> # difference between naive datetimes
>>> NaiveDateTime(2023, 12, 28, 11) - NaiveDateTime(2023, 12, 27, 11)
TimeDelta(24:00:00)

Adding and subtracting time

You can add or subtract various units of time from a datetime instance.

>>> d = ZonedDateTime(2023, 12, 28, 11, 30, tz="Europe/Amsterdam")
>>> d.add(hours=5, minutes=30)
ZonedDateTime(2023-12-28 17:00:00+01:00[Europe/Amsterdam])
>>> d.subtract(days=1)  # 1 day earlier
ZonedDateTime(2023-12-27 11:30:00+01:00[Europe/Amsterdam])

Adding/subtracting takes into account timezone changes (e.g. daylight saving time) according to industry standard RFC 5545. This means:

  • Units are added from largest (year) to smallest (microsecond), truncating and/or wrapping at each step.

  • Precise time units (hours, minutes, and seconds) account for DST changes, but calendar units (days, months, years) do not. The expectation is that rescheduling a 10am appointment “a day later” will still be at 10am, even after DST changes.

See also

Have a look at the documentation on deltas for more details on arithmetic operations, as well as more advanced features.

Attention

OffsetDateTime instances do not support moving back and forwards in time, because offsets in real world timezones aren’t always constant. That is, the offset may be different after moving backwards or forwards in time. If you need to shift an OffsetDateTime instance, either convert to UTC or a proper timezone first.

Ambiguity in timezones

Note

The API for handling ambiguitiy is inspired by that of Temporal, the redesigned date and time API for JavaScript.

In real-world timezones, local clocks are often moved backwards and forwards due to Daylight Saving Time (DST) or political decisions. This creates two types of situations for the ZonedDateTime and LocalSystemDateTime types:

  • When the clock moves backwards, there is a period of time that occurs twice. For example, Sunday October 29th 2:30am occured twice in Paris. When you specify this time, you need to specify whether you want the earlier or later occurrence.

  • When the clock moves forwards, a period of time is skipped. For example, Sunday March 26th 2:30am didn’t happen in Paris. When you specify this time, you need to specify how you want to handle this non-existent time. Common approaches are to extrapolate the time forward or backwards to 1:30am or 3:30am.

By default, whenever refuses to guess, but it is possible to customize how to handle these situations. You choose the disambiguation behavior you want with the disambiguate= argument:

disambiguate

Behavior in case of ambiguity

"raise"

(default) Refuse to guess: raise AmbiguousTime or SkippedTime exception.

"earlier"

Choose the earlier of the two options

"later"

Choose the later of the two options

"compatible"

Choose “earlier” for backward transitions and “later” for forward transitions. This matches the behavior of other established libraries, and the industry standard RFC 5545. It corresponds to setting fold=0 in the standard library.

>>> paris = "Europe/Paris"

>>> # Not ambiguous: everything is fine
>>> ZonedDateTime(2023, 1, 1, tz=paris)
ZonedDateTime(2023-01-01 00:00:00+01:00[Europe/Paris])

>>> # Ambiguous: 1:30am occurs twice. Refuse to guess.
>>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris)
Traceback (most recent call last):
  ...
whenever.AmbiguousTime: 2023-10-29 02:30:00 is ambiguous in timezone Europe/Paris

>>> # Ambiguous: explicitly choose the earlier option
>>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguate="earlier")
ZoneDateTime(2023-10-29 02:30:00+01:00[Europe/Paris])

>>> # Skipped: 2:30am doesn't exist.
>>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris)
Traceback (most recent call last):
  ...
whenever.SkippedTime: 2023-03-26 02:30:00 is skipped in timezone Europe/Paris

>>> # Non-existent: extrapolate to 3:30am
>>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris, disambiguate="later")
ZonedDateTime(2023-03-26 03:30:00+02:00[Europe/Paris])

Standardized representations

Whenever supports various standardized representations of datetimes.

ISO 8601

The ISO 8601 standard is probably the format you’re most familiar with. What you may not know is that it’s a very complex standard with many options. Like most libraries, whenever supports a subset of the standard which is the most commonly used:

YYYY-MM-DDTHH:MM:SS±HH:MM

For example: 2023-12-28T11:30:00+05:00

Where:

  • Seconds may be fractional

  • The offset may be replaced with a "Z" to indicate UTC

  • Offset -00:00 is not allowed

Use the methods common_iso8601() and from_common_iso8601() to format and parse to this format, respectively:

>>> d = OffsetDateTime(2023, 12, 28, 11, 30, offset=+5)
>>> d.common_iso8601()
'2023-12-28T11:30:00+05:00'
>>> OffsetDateTime.from_common_iso8601('2021-07-13T09:45:00-09:00')
OffsetDateTime(2021-07-13 09:45:00-09:00)

Why not support the full ISO 8601 spec?

The full ISO 8601 standard is not supported for several reasons:

  • It allows for a lot of rarely-used flexibility: e.g. fractional hours, omitting separators, week-based years, etc.

  • There are different versions of the standard with different rules

  • The full specification is not freely available

This isn’t a problem in practice since people referring to “ISO 8601” often mean the most common subset, which is what whenever supports. It’s rare for libraries to support the full standard. The method name from_common_iso8601 makes this assumption explicit.

If you do need to parse the full spectrum of ISO 8601, you can use a specialized library such as dateutil.parser. If possible, it’s recommend to use the RFC 3339 format instead.

RFC 3339

RFC 3339 is a subset of ISO 8601 with a few deviations. The format is:

YYYY-MM-DDTHH:MM:SS±HH:MM

For example: 2023-12-28T11:30:00+05:00

Where:

  • Seconds may be fractional

  • The offset may be replaced with a "Z" to indicate UTC

  • T may be replaced with a space (unlike ISO 8601)

  • T and Z may be lowercase (unlike ISO 8601)

Use the methods rfc3339() and from_rfc3339() to format and parse to this format, respectively:

>>> d = OffsetDateTime(2023, 12, 28, 11, 30, offset=+5)
>>> d.rfc3339()
'2023-12-28T11:30:00+05:00'
>>> OffsetDateTime.from_rfc3339('2021-07-13 09:45:00Z')
OffsetDateTime(2021-07-13 09:45:00Z)

RFC 2822

RFC 2822 is another common format for representing datetimes. It’s used in email headers and HTTP headers. The format is:

Weekday, DD Mon YYYY HH:MM:SS ±HHMM

For example: Tue, 13 Jul 2021 09:45:00 -0900

Use the methods rfc2822() and from_rfc2822() to format and parse to this format, respectively:

>>> d = OffsetDateTime(2023, 12, 28, 11, 30, offset=+5)
>>> d.rfc2822()
'Thu, 28 Dec 2023 11:30:00 +0500'
>>> OffsetDateTime.from_rfc2822('Tue, 13 Jul 2021 09:45:00 -0900')
OffsetDateTime(2021-07-13 09:45:00-09:00)

To and from the standard library

Each whenever datetime class can be converted to a standard library datetime with the py_datetime() method. Conversely, you can create a type from a standard library datetime with the from_py_datetime() classmethod.

>>> from datetime import datetime, UTC
>>> UTCDateTime.from_py_datetime(datetime(2023, 1, 1, tzinfo=UTC))
UTCDateTime(2023-01-01 00:00:00Z)
>>> ZonedDateTime(2023, 1, 1, tz="Europe/Amsterdam").py_datetime()
datetime(2023, 1, 1, 0, 0, tzinfo=ZoneInfo('Europe/Amsterdam'))

Parsing

For now, basic parsing functionality is implemented in the strptime() methods of UTCDateTime, OffsetDateTime, and NaiveDateTime. As the name suggests, these methods are thin wrappers around the standard library strptime() function. The same formatting rules apply.

UTCDateTime.strptime("2023-01-01 12:30", "%Y-%m-%d %H:%M")  # 2023-01-01 12:30:00Z
OffsetDateTime.strptime("2023-01-01+05:00", "%Y-%m-%d%z")  # 2023-01-01 00:00:00+05:00
NaiveDateTime.strptime("2023-01-01 00:00", "%Y-%m-%d %H:%M")  # 2023-01-01 00:00:00

ZonedDateTime and LocalSystemDateTime do not (yet) implement strptime() methods, because they require disambiguation. If you’d like to parse into these types, use NaiveDateTime.strptime() to parse them, and then use the assume_utc(), assume_offset(), assume_zoned(), or assume_local() methods to convert them. This makes it explicit what information is being assumed.

NaiveDateTime.strptime("2023-01-01 12:00", "%Y-%m-%d %H:%M").assume_local()

# handling ambiguity
NaiveDateTime.strptime("2023-10-29 02:30:00", "%Y-%m-%d %H:%M:%S").assume_zoned(
    "Europe/Amsterdam",
    disambiguate="earlier",
)

Future plans

Python’s builtin strptime has its limitations, so a more full-featured parsing API may be added in the future.

Serialization

Canonical string format

Each type has a canonical textual format, which is used when converting to and from strings. The canonical format is designed to be unambiguous, and to preserve all information. This makes it ideal for storing datetimes in a database, or inclusing in JSON.

Here are the canonical formats for each type:

Type

Canonical string format

UTCDateTime

YYYY-MM-DDTHH:MM:SS(.ffffff)Z

OffsetDateTime

YYYY-MM-DDTHH:MM:SS(.ffffff)±HH:MM(:SS(.ffffff))

ZonedDateTime

YYYY-MM-DDTHH:MM:SS(.ffffff)±HH:MM(:SS(.ffffff))[TIMEZONE ID]

LocalSystemDateTime

YYYY-MM-DDTHH:MM:SS(.ffffff)±HH:MM(:SS(.ffffff))

NaiveDateTime

YYYY-MM-DDTHH:MM:SS(.ffffff)

>>> UTCDateTime(2023, 1, 1, 0, 0).canonical_format()
'2023-01-01T00:00:00Z'
>>> ZonedDateTime.from_canonical_format('2022-10-24T19:00:00+02:00[Europe/Paris]')
ZonedDateTime(2022-10-24 19:00:00+02:00[Europe/Paris])

See also

The methods canonical_format() and from_canonical_format() can be used to convert to and from the canonical string format.

Note

The canonical format is similar to existing standards like ISO 8601 and RFC 3339. If parsing from these formats, it’s recommended to use from_common_iso8601() or from_rfc3339() over from_canonical_format(). These methods are more explicit and generally more lenient in what they accept.

Pickling

All types are pickleable, so you can use them in a distributed system or store them in a database that supports pickling.

import pickle

d = UTCDateTime(2023, 1, 1, 0, 0)
pickled = pickle.dumps(d)
unpickled = pickle.loads(pickled)
assert d == unpickled

Note

From version 1.0 onwards, we aim to maintain backwards compatibility for unpickling.

Date and time components

Aside from the datetimes themselves, whenever also provides Date for calendar dates and Time for representing times of day.

>>> from whenever import Date, Time
>>> Date(2023, 1, 1)
Date(2023-01-01)
>>> Time(12, 30)
Time(12:30:00)

These types can be converted to datetimes and vice versa:

>>> Date(2023, 1, 1).at(Time(12, 30))
NaiveDateTime(2023-01-01 12:30:00)
>>> UTCDateTime.now().date()
Date(2023-07-13)

See the API reference for more details.

The local system timezone

The local timezone is the timezone of the system running the code. It’s important to be aware that the local timezone can change. Instances of LocalSystemDateTime have the fixed offset of the system timezone at the time of initialization. The system timezone may change afterwards, but instances of this type will not reflect that change. This is because:

  • There are several ways to deal with such a change: should the moment in time be preserved, or the local time on the clock?

  • Automatically reflecting that change would mean that the object could change at any time, depending on some global mutable state. This would make it harder to reason about and use.

>>> # initialization where the system timezone is America/New_York
>>> d = LocalSystemDateTime(2020, 8, 15, hour=8)
LocalSystemDateTime(2020-08-15 08:00:00-04:00)
...
>>> # we change the system timezone to Amsterdam
>>> os.environ["TZ"] = "Europe/Amsterdam"
>>> time.tzset()
...
>>> d  # object remains unchanged
LocalSystemDateTime(2020-08-15 08:00:00-04:00)

If you’d like to preserve the moment in time and calculate the new local time, simply call as_local().

>>> # same moment, but now with the clock time in Amsterdam
>>> d.as_local()
LocalSystemDateTime(2020-08-15 14:00:00+02:00)

On the other hand, if you’d like to preserve the local time on the clock and calculate the corresponding moment in time:

>>> # take the wall clock time...
>>> wall_clock = d.naive()
NaiveDateTime(2020-08-15 08:00:00)
>>> # ...and assume the system timezone (Amsterdam)
>>> wall_clock.assume_local()
LocalSystemDateTime(2020-08-15 08:00:00+02:00)

Note

Remember that assume_local() may require disambiguation, if the wall clock time is ambiguous in the system timezone.