--- tocdepth: 3 myst: html_meta: description: >- A detailed comparison with Pendulum: why an API cannot be fixed by subclassing it, and how four further design decisions — treat naive datetimes as a mistake, redefine fold, put months inside timedelta, and complete the input rather than reject it — produce failures in equality, arithmetic, DST handling, and parsing that cannot be patched out. --- # Why not Pendulum? [**Pendulum**](https://pypi.org/project/pendulum/) arrived in 2016 with several improvements over the standard library: a fluent API, localization, and the main attraction — arithmetic that knows about DST. All this delivered as a drop-in replacement: Pendulum's classes subclass the standard library's, so existing code keeps working. But this promise is a trap: a drop-in replacement [can't *also* change behavior](https://wiki.c2.com/?LiskovSubstitutionPrinciple). Fixing an API this way may work in the short term, but it invites new contradictions and footguns. Most of the problems below trace back to Pendulum's design, not its implementation.[^versions] Subclassing isn't the only decision with lasting consequences. Pendulum also {ref}`assumes UTC ` when a time zone is missing, {ref}`redefines fold `, {ref}`puts months inside a timedelta `, and {ref}`completes input ` instead of rejecting it. None of these can be fixed without breaking existing code. ```{admonition} Pendulum 3.3 at a glance :class: caution A sample of behavior you can reproduce today: - [The result of `parse("12:00")` depends on *when* you run it](#pendulum-parse-now) - [`today("Africa/Cairo")` is *yesterday*](#pendulum-today-yesterday), for one whole day each year - [A 3-hour interval reports `.hours == 4`](#pendulum-intervals) - [`end - (end - start)` can land *years* before `start`](#pendulum-intervals) - [`instance(a_datetime)` can move the instant by an hour](#pendulum-gap-fold) ``` (pendulum-decision-dropin)= ## The drop-in trap > It provides classes that are drop-in replacements for the native ones (they > inherit from them). > > — [Pendulum's documentation](https://pendulum.eustace.io/docs/#introduction) Drop-in compatibility is Pendulum's main selling point, and it does this by subclassing. Subclassing is also a well-worn trap. While a subclass is free to *add* behavior, the moment it *changes* behavior, every piece of code ever written against the base class can [break when handed this subclass](https://wiki.c2.com/?LiskovSubstitutionPrinciple). Pendulum's main attraction, arithmetic that knows about DST, is exactly such a behavior change. A subclass of `datetime` either fixes how `+` behaves, *or* is a drop-in replacement. It cannot be both. (pendulum-add-stack)= ### Code written for `datetime` gets Pendulum's `+` A `DateTime` *is* a `datetime`, so it goes wherever a `datetime` is accepted: into your own code, into other libraries, and into the standard library itself. All of that code was written for the standard library's `+`, and now gets Pendulum's. Take a helper that moves a datetime to the same time tomorrow. For a `datetime`, that is exactly what `+ timedelta(days=1)` does: ```python >>> from datetime import datetime, timedelta >>> from zoneinfo import ZoneInfo >>> def same_time_tomorrow(d: datetime) -> datetime: ... return d + timedelta(days=1) >>> same_time_tomorrow(datetime(2013, 3, 30, 12, tzinfo=ZoneInfo("Europe/Paris"))) datetime.datetime(2013, 3, 31, 12, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Paris')) >>> same_time_tomorrow(pendulum.datetime(2013, 3, 30, 12, tz="Europe/Paris")) DateTime(2013, 3, 31, 13, 0, 0, tzinfo=Timezone('Europe/Paris')) ``` The standard library's own methods are no exception. `datetime.astimezone()` converts through the time zone's `fromutc()`, which calls `+`. Called on a Pendulum value, that `+` is Pendulum's, and the conversion goes wrong: ```python >>> d = pendulum.datetime(2024, 7, 1, 12, tz="UTC") >>> d.astimezone(ZoneInfo("Europe/Paris")) # Pendulum's method DateTime(2024, 7, 1, 14, 0, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Paris')) >>> datetime.astimezone(d, ZoneInfo("Europe/Paris")) # the standard library's DateTime(2024, 7, 1, 12, 0, 0) # naive, and not 14:00 ``` Pendulum's own `astimezone()` avoids this by handing the standard library a plain `datetime`. It can't do the same for code it doesn't control: every function that does arithmetic on a `datetime` it is given is in the position of `same_time_tomorrow()`. Pendulum cannot change that code, cannot un-redefine `+` (that is the main attraction), and cannot stop being a subclass (that is the selling point). Until version 3.3, Pendulum handled the conversion case by inspecting the call stack inside `+`. "[Why Pendulum had to write the most cursed `+` operator in all of Python](https://dev.arie.bovenberg.net/blog/pendulum-cursed-plus-operator/)" tells how it got there. (pendulum-duration-dropin)= ### Code written for `timedelta` gets Pendulum's `Duration` `Duration` subclasses `timedelta`, and changes its behavior the same way. A `timedelta` is normalized: `days` carries the sign, and `seconds` is always between 0 and 86399. Code that splits a `timedelta` into its fields relies on this. Pendulum skips the normalization, so a `Duration`'s fields no longer add up to its value: ```python >>> timedelta(seconds=-1).days, timedelta(seconds=-1).seconds (-1, 86399) >>> delta = pendulum.duration(seconds=-1) >>> delta.days, delta.seconds (-1, -1) >>> timedelta(days=delta.days, seconds=delta.seconds) # rebuilt from its fields datetime.timedelta(days=-2, seconds=86399) ``` Months exist only on Pendulum's side. Code written for `timedelta` sees a month as 30 days, so the standard library's `+` lands on a different date: ```python >>> month = pendulum.duration(months=1) >>> pendulum.datetime(2024, 1, 31) + month DateTime(2024, 2, 29, 0, 0, 0, tzinfo=Timezone('UTC')) >>> datetime(2024, 1, 31) + month datetime.datetime(2024, 3, 1, 0, 0) ``` :::{admonition} How `whenever` does it :class: tip `whenever`'s types don't subclass `datetime`, so no code can mistake one for a `datetime` and apply the wrong arithmetic. [Converting to and from the standard library](guide/stdlib-convert.md) is an explicit step. It's also part of why `whenever`'s own types {ref}`can't be subclassed `. ::: (pendulum-substitutable)= ### What does drop-in buy? The "drop-in replacement" promise also fails in a blunter way: some code refuses a Pendulum value outright. Many libraries check `type()` rather than `isinstance()`. `sqlite3`, for example, binds a `datetime` and rejects its subclass: ```python >>> con.execute("select ?", (datetime(2024, 1, 1),)).fetchone() ('2024-01-01 00:00:00',) >>> con.execute("select ?", (pendulum.datetime(2024, 1, 1),)).fetchone() Traceback (most recent call last): ... ProgrammingError: Error binding parameter 1: type 'DateTime' is not supported ``` Pendulum's documentation [lists](https://pendulum.eustace.io/docs/#limitations) `sqlite3`, `mysqlclient`, `PyMySQL`, and Django as known cases, and calls its own list "non-exhaustive". It can never be exhaustive: checking the exact type is ordinary practice, and no subclass can stop other people's code from doing it. Even Pendulum's own Rust extension does it: `precise_diff` checks the exact type of its second argument ([#906](https://github.com/python-pendulum/pendulum/issues/906)). (pendulum-zoneinfo-cache)= ### Subclassing `ZoneInfo` forfeits its cache `datetime` isn't the only class Pendulum subclasses. `pendulum.Timezone` subclasses `ZoneInfo`, and `zoneinfo`'s strong cache serves only the exact base type. When no `Timezone("Asia/Tokyo")` object is alive, the next `tz="Asia/Tokyo"` re-reads and re-parses the tzdata file: ```python >>> from timeit import timeit >>> timeit('ZoneInfo("Asia/Tokyo")', globals=globals(), number=10_000) 0.003 >>> timeit('pendulum.timezone("Asia/Tokyo")', globals=globals(), number=10_000) 0.75 ``` (pendulum-performance)= ### Arithmetic is about 100 times slower Pendulum's FAQ lists [improved performance](https://pendulum.eustace.io/faq/) as a goal, but its `+` is about 100 times slower than the standard library's: ```python >>> hour = timedelta(hours=1) >>> std = datetime(2024, 1, 1, tzinfo=ZoneInfo("Europe/Paris")) >>> pdl = pendulum.datetime(2024, 1, 1, tz="Europe/Paris") >>> timeit("std + hour", globals=globals(), number=1_000_000) # seconds 0.08 >>> timeit("pdl + hour", globals=globals(), number=1_000_000) 8.6 ``` Pendulum's arithmetic is written in pure Python, even in the build with the Rust extension. ## Four more decisions with a cost Subclassing is the biggest decision Pendulum is built on, but not the only one. Four more decisions run just as deep and cannot be undone without changing what existing code computes: - **{ref}`Treat naive datetimes as a mistake to be corrected `** rather than as a distinct kind of value. - **{ref}`Redefine fold `** with a different default, and the opposite meaning in a gap. - **{ref}`Put months and years inside a timedelta `** alongside a fixed number of seconds. - **{ref}`Complete the input rather than reject it `** by filling what is missing from the clock and the machine's time zone. (pendulum-decision-naive)= ### Treat naive datetimes as a mistake to be corrected > Pendulum enforces timezone aware datetimes, and using them is the preferred > and recommended way of using the library. > > — [Pendulum's documentation](https://pendulum.eustace.io/docs/#instantiation) The instinct is good: naive datetimes really are a common source of bugs. But Pendulum treats them as an error to be corrected rather than a value with its own meaning. Naive values still exist though, and Pendulum even gives them extra behavior on top of the standard library's already [complex semantics](stdlib-pitfalls/naive-meaning.md). (pendulum-utc-assumption)= #### Missing time zone information becomes UTC If a naive result is unacceptable, then Pendulum must invent a time zone where one is missing. Most often, this is UTC: ```python >>> pendulum.parse("2024-03-10T15:00") DateTime(2024, 3, 10, 15, 0, 0, tzinfo=Timezone('UTC')) >>> pendulum.instance(datetime(2024, 3, 10, 15)) DateTime(2024, 3, 10, 15, 0, 0, tzinfo=Timezone('UTC')) ``` UTC is a reasonable guess. It is still a guess. A timestamp that says "15:00" and nothing else might come from a server in UTC, or from a user in Tokyo. Pendulum turns an honest "unknown" into a confident "UTC", and nothing downstream can tell the difference. If the guess is wrong, the instant is silently off by hours. Pendulum's entry points also disagree about what a missing time zone means: | entry point | a zoneless input becomes | |---|---| | `pendulum.parse(...)` | UTC | | `pendulum.instance(...)` | UTC | | `DateTime.strptime(...)` | UTC | | `pendulum.from_timestamp(...)` | UTC | | `DateTime.fromtimestamp(...)` | the system time zone | | `DateTime.combine(...)` | naive | Even passing a time zone explicitly is inconsistent: given a `tz`, `instance()` applies it to a standard-library naive value, but returns Pendulum's own naive values *unchanged*: ```python >>> pendulum.instance(datetime(2024, 1, 1), tz="Europe/Paris") DateTime(2024, 1, 1, 0, 0, 0, tzinfo=Timezone('Europe/Paris')) >>> pendulum.instance(pendulum.naive(2024, 1, 1), tz="Europe/Paris") DateTime(2024, 1, 1, 0, 0, 0) ``` (pendulum-naive-meanings)= #### Naive datetimes accumulate even more meanings The standard library already overloads a naive datetime with three readings: system-local time (`timestamp()`, `astimezone()`), plain calendar fields (comparison and arithmetic), and UTC (`utcnow()`, `utcfromtimestamp()`). Notably, the standard library has been *retiring* that third reading: `utcnow()` and `utcfromtimestamp()` are deprecated since Python 3.12, precisely because naive-but-actually-UTC values are a bug factory. Pendulum keeps all three inherited readings, extends the deprecated one (`parse()`, `instance()`, and `DateTime.strptime()` all assume UTC) and adds a fourth of its own: `in_timezone()` reads a naive value as "already in whatever time zone you name". ```python >>> n = pendulum.naive(2024, 1, 1) >>> n.tzname() is None # inherited: no time zone True >>> n.astimezone(pendulum.UTC) # inherited: local (e.g. New York) DateTime(2024, 1, 1, 5, 0, 0, tzinfo=Timezone('UTC')) >>> pendulum.instance(datetime(2024, 1, 1)) # extended: it is UTC DateTime(2024, 1, 1, 0, 0, 0, tzinfo=Timezone('UTC')) >>> n.in_tz("Europe/Paris") # new: "it is already Paris" DateTime(2024, 1, 1, 0, 0, 0, tzinfo=Timezone('Europe/Paris')) ``` The method, not the value, decides which reading applies: the same `n` denotes three different instants across the five calls above. The standard library is retiring one of these meanings; Pendulum has four. :::{admonition} How `whenever` does it :class: tip `whenever` keeps datetimes without a time zone — as {class}`~whenever.PlainDateTime`, which holds a {term}`local time` — but makes them a separate type rather than an incomplete aware one. A `PlainDateTime` has no time zone and never acquires one implicitly: there is no reading of it as UTC, as system-local, or as "already in whatever time zone you name". Turning one into an instant is an explicit call — {meth}`~whenever.PlainDateTime.assume_tz`, {meth}`~whenever.PlainDateTime.assume_utc`, or {meth}`~whenever.PlainDateTime.assume_fixed_offset` — whose name says which assumption you are making. Mixing the two kinds up is a type error rather than a silent shift of several hours. ::: (pendulum-decision-fold)= ### Redefine `fold` rather than adopt it > Here, 2:30 exists twice in the day so pendulum will assume that the > transition already occurred. > > — [Pendulum's documentation](https://pendulum.eustace.io/docs/#normalization) The `fold` flag (PEP 495) on `datetime` is the standard library's way to tell apart the two occurrences of a repeated local time. Pendulum keeps the flag but gives it a meaning of its own: `fold` picks which side of the transition the result lands on, `0` for before and `1` for after. That's a deliberate and internally consistent model. It's also not PEP 495's, and values cross between the two libraries all the time. (pendulum-fold-default)= #### The default is different from everything else Pendulum defaults to `fold=1`, selecting the offset *after* a backwards transition. Python's standard library and the convention used by most datetime libraries default to the {ref}`offset before the transition`. This default is significant: moving code from `datetime` to Pendulum can change which instant a repeated local time represents. The default also has further implications. (pendulum-gap-fold)= #### For skipped times, `fold` means the opposite of what it means in PEP 495 When the clock jumps from 02:00 to 03:00, a time like 02:30 doesn't exist. `fold` decides where it lands, and Pendulum decides the opposite of the standard library: | skipped 02:30 becomes | `fold=0` | `fold=1` | |---|---|---| | standard library (PEP 495) | 03:30 | 01:30 | | Pendulum | 01:30 | 03:30 | The reversal bites when converting. `instance()` copies the standard library's `fold`, so a skipped time moves by an hour on its way into Pendulum. And since `pendulum.datetime()` defaults to `fold=1`, Pendulum's own entry points disagree about the same wall time: ```python >>> std = datetime(2013, 3, 31, 2, 30, tzinfo=ZoneInfo("Europe/Paris")) # skipped time >>> std.timestamp() 1364693400.0 >>> pendulum.instance(std), pendulum.instance(std).timestamp() (DateTime(2013, 3, 31, 1, 30, 0, tzinfo=Timezone('Europe/Paris')), 1364689800.0) >>> pendulum.instance(datetime(2013, 3, 31, 2, 30), tz="Europe/Paris") DateTime(2013, 3, 31, 1, 30, 0, tzinfo=Timezone('Europe/Paris')) >>> pendulum.datetime(2013, 3, 31, 2, 30, tz="Europe/Paris") DateTime(2013, 3, 31, 3, 30, 0, tzinfo=Timezone('Europe/Paris')) ``` (pendulum-today-yesterday)= #### `today()` can be yesterday The choice of `fold` leaks into operations that never mention it. `start_of()`, `replace()`, `at()`, and several other methods re-resolve the wall time using the value's existing `fold`. So does `today()`. In a time zone where DST starts at midnight, midnight itself is skipped once a year. Egypt, Chile, Cuba, Lebanon, and the Azores all do this. On that day, `today()` resolves the skipped midnight *backwards*, into the previous day: ```python >>> with pendulum.travel_to(pendulum.datetime(2025, 4, 25, 12, tz="Africa/Cairo"), freeze=True): ... pendulum.today("Africa/Cairo") DateTime(2025, 4, 24, 23, 0, 0, tzinfo=Timezone('Africa/Cairo')) ``` Reported in [#915](https://github.com/python-pendulum/pendulum/issues/915); a fix is proposed in [#994](https://github.com/python-pendulum/pendulum/pull/994). (pendulum-equality)= #### Equal values can be an hour apart The standard library's [equality quirks](stdlib-pitfalls/broken-equality.md) apply to Pendulum too: within one time zone, `==` and `<` compare the local time and ignore `fold`. The standard library is at least consistent about it, because its `-` ignores `fold` as well. Pendulum redefines `-` to measure elapsed time, but inherits `==` and `<`. Its operators now contradict each other: ```python >>> f0 = pendulum.datetime(2023, 11, 5, 1, 25, tz="America/Los_Angeles", fold=0) >>> f1 = f0.replace(fold=1) >>> f0 == f1 True >>> (f1 - f0).total_seconds() # the standard library says 0 3600.0 >>> later = pendulum.datetime(2023, 11, 5, 1, 15, tz="America/Los_Angeles", fold=1) >>> earlier = pendulum.datetime(2023, 11, 5, 1, 25, tz="America/Los_Angeles", fold=0) >>> later < earlier True >>> (later - earlier).total_seconds() # the standard library says -600 3000.0 ``` Two more things differ from the standard library. Pendulum's `repr()` omits `fold`, so `f0` and `f1` print identically. And Pendulum's documentation [says](https://pendulum.eustace.io/docs/#comparison) "the comparison is done in the UTC timezone". If it were, `f0 == f1` would be `False`, and this would be `True`: ```python >>> f0 == f0.in_tz("UTC") False ``` First reported in [#351](https://github.com/python-pendulum/pendulum/issues/351), which is still open. :::{admonition} How `whenever` does it :class: tip Ambiguity in `whenever` is settled by an explicit {ref}`disambiguation ` argument on the operation that creates the value, defaulting to `"compatible"` — the same convention as RFC 5545, Temporal, NodaTime, and the standard library's `fold=0` — with `"raise"` available when you would rather be told than guessed at. Relying on the default emits {class}`~whenever.ImplicitDisambiguationWarning`, so the guess is never silent. The resolved offset is part of the value, so copying, pickling, and converting it preserve the instant. ::: (pendulum-decision-months)= ### Put months and years inside a `timedelta` > Even though it inherits from the `timedelta` class, its behavior is slightly > different. The more important to notice is that the native normalization > does not happen, this is so that it feels more intuitive. > > — [Pendulum's documentation](https://pendulum.eustace.io/docs/#duration) A `Duration` has to express both "90 minutes" and "one month", and Pendulum stores them in one object: the `timedelta` it inherits for the exact part, extra integer fields beside it for the calendar part. Applying such an object then requires a choice between clock and elapsed arithmetic, which Pendulum makes for you, from context. (pendulum-durations)= #### Durations lose their calendar units Pendulum's `Duration` adds years and months to `timedelta`. That is a difficult fit: `timedelta` represents an exact elapsed duration, while a calendar month has no fixed length. Pendulum approximates a month as 30 days for compatibility with `timedelta`, while applying a month to a datetime uses calendar arithmetic. Consequently, values that compare equal are not interchangeable: ```python >>> month = pendulum.duration(months=1) >>> thirty_days = pendulum.duration(days=30) >>> month == thirty_days, hash(month) == hash(thirty_days) (True, True) >>> jan_31 = pendulum.datetime(2024, 1, 31) >>> jan_31 + month DateTime(2024, 2, 29, 0, 0, 0, tzinfo=Timezone('UTC')) >>> jan_31 + thirty_days DateTime(2024, 3, 1, 0, 0, 0, tzinfo=Timezone('UTC')) ``` Because the calendar fields live beside the `timedelta` rather than in it, ordinary operations erase them: ```python >>> month + pendulum.duration() Duration(weeks=4, days=2) >>> month * 1.0 Duration() >>> month / 2 Duration() >>> pendulum.duration(years=1) / 2, pendulum.duration(years=3) / 2 (Duration(), Duration(years=2)) >>> import copy, pickle >>> copy.copy(month), pickle.loads(pickle.dumps(month)) (Duration(weeks=4, days=2), Duration(weeks=4, days=2)) ``` Operators Pendulum doesn't override fall back to `timedelta`'s and return the base class, dropping the calendar fields the same way: ```python >>> abs(pendulum.duration(months=-1)) datetime.timedelta(days=30) >>> type(+pendulum.duration(days=1)) ``` Compatibility with the `timedelta` base class is asymmetric: `timedelta(hours=1) / day` works, `day / timedelta(hours=1)` raises `AttributeError: 'datetime.timedelta' object has no attribute '_to_microseconds'`, as do floor division, modulo, and `divmod()` ([#382](https://github.com/python-pendulum/pendulum/issues/382), since 2019; these operators ignore months altogether, [#799](https://github.com/python-pendulum/pendulum/issues/799)). Code written for `timedelta` meets {ref}`further surprises `. (pendulum-implicit-arithmetic)= #### Calendar and exact arithmetic are chosen implicitly Pendulum's `add()` and `subtract()` are a real improvement over `datetime`: they distinguish calendar days from elapsed hours. The trouble is that Pendulum picks *which kind* of arithmetic to apply from incidental details: which other units appear in the same call, and whether you wrote `+` or `-`. If any calendar unit is present in an `add()` call, Pendulum applies *all* units to the local clock, hours included: ```python >>> b = pendulum.datetime(2024, 3, 30, 1, 30, tz="Europe/Paris") >>> b.add(days=1, hours=2) DateTime(2024, 3, 31, 3, 30, 0, tzinfo=Timezone('Europe/Paris')) >>> b.add(days=1).add(hours=2) DateTime(2024, 3, 31, 4, 30, 0, tzinfo=Timezone('Europe/Paris')) ``` Reported in [#838](https://github.com/python-pendulum/pendulum/issues/838); a fix is proposed in [#1038](https://github.com/python-pendulum/pendulum/pull/1038). `+` decomposes a `Duration` into units; `-` collapses it to seconds. So `dt - delta` differs from both `dt.subtract(...)` and `dt + -delta`: ```python >>> dt = pendulum.datetime(2013, 4, 2, tz="Europe/Paris") >>> three_days = pendulum.duration(days=3) >>> dt.subtract(days=3) DateTime(2013, 3, 30, 0, 0, 0, tzinfo=Timezone('Europe/Paris')) >>> dt - three_days DateTime(2013, 3, 29, 23, 0, 0, tzinfo=Timezone('Europe/Paris')) >>> dt + -three_days DateTime(2013, 3, 30, 0, 0, 0, tzinfo=Timezone('Europe/Paris')) ``` A fix for the last case is proposed in [pull request #987](https://github.com/python-pendulum/pendulum/pull/987). (pendulum-intervals)= #### Intervals disagree with themselves Subtracting two datetimes gives an `Interval`, which adds a second representation on top of `Duration`'s: it stores the elapsed seconds *and*, separately, a calendar decomposition (years, months, days, hours, and smaller units) computed from the local clock fields. When the two ends share a time zone, that decomposition ignores DST, so the two views disagree and the round trip fails: ```python >>> s = pendulum.datetime(2024, 3, 30, 23, 30, tz="Europe/Paris") >>> e = pendulum.datetime(2024, 3, 31, 3, 30, tz="Europe/Paris") # 3 hours later >>> iv = e - s >>> iv.total_seconds(), iv.in_hours() (10800.0, 3) >>> iv.hours, iv.in_words() (4, '4 hours') >>> s + iv DateTime(2024, 3, 31, 4, 30, 0, tzinfo=Timezone('Europe/Paris')) # not e ``` Subtraction uses the decomposition *and* the total, so it double-counts anything longer than a month: ```python >>> s = pendulum.datetime(2020, 1, 1) >>> e = pendulum.datetime(2024, 6, 1) >>> s + (e - s) == e True >>> e - (e - s) DateTime(2015, 8, 2, 0, 0, 0, tzinfo=Timezone('UTC')) # expected 2020-01-01 ``` (pendulum-interval-equality)= An `Interval`'s equality semantics don't form a valid equivalence relation, and equal objects can hash differently — a defect the base `timedelta` doesn't have: ```python >>> jan = pendulum.interval(pendulum.datetime(2024, 1, 1), pendulum.datetime(2024, 1, 2)) >>> feb = pendulum.interval(pendulum.datetime(2024, 2, 1), pendulum.datetime(2024, 2, 2)) >>> one_day = pendulum.duration(days=1) >>> jan == one_day, one_day == feb, jan == feb (True, True, False) >>> hash(jan) == hash(one_day), len({jan, one_day}) (False, 2) ``` That combination violates Python's contract for hashable objects and can produce incorrect behavior in dictionaries, sets, caches, and deduplication code. All of the above has one cause: an `Interval` stores two answers to one question and consults them inconsistently. Its smaller oddities are cataloged {ref}`with the other bugs `. :::{admonition} How `whenever` does it :class: tip In `whenever` the unit decides, not the call: `hours` is always exact elapsed time and `days` is always calendar, whichever other units appear alongside them, and `-`, `subtract()` and adding a negated delta all take the same path. {ref}`Deltas ` keep their calendar and exact parts separately rather than collapsing one into the other, so units survive arithmetic, copying and pickling. ::: (pendulum-decision-parse)= ### Complete the input rather than reject it > If you pass a non-standard or more complicated string, it will raise an > exception, so it is advised to use the `from_format()` helper instead. > > — [Pendulum's documentation](https://pendulum.eustace.io/docs/#parsing) (pendulum-parse-now)= When a string is *incomplete*, Pendulum's `parse()` doesn't refuse it. Even when passing `strict=True`. Instead, it supplies the missing fields from the clock. A time-only string becomes a datetime on *today's* date. "Today" is read off the machine's local clock, while the result is stamped UTC, and any offset in the input is discarded. On a machine in UTC+12, you'll get *tomorrow's* UTC date about half of the time. ```python >>> pendulum.parse("12:34:56") DateTime(2026, 8, 24, 12, 34, 56, tzinfo=Timezone('UTC')) >>> pendulum.parse("12:34:56+05:00") # offset dropped, too DateTime(2026, 8, 24, 12, 34, 56, tzinfo=Timezone('UTC')) >>> pendulum.today("UTC") DateTime(2026, 8, 23, 0, 0, 0, tzinfo=Timezone('UTC')) ``` The date comes from the clock and the time zone {ref}`from a guess `, all while ignoring the provided offset. An implicit UTC assumption at least serves the people whose data is in UTC. It's hard to think of a program that wants the local date, stamped UTC, with its offset discarded. The string `"now"` is special-cased (and undocumented): ```python >>> pendulum.parse("now") DateTime(2026, 8, 23, 14, 32, 19, 426001, tzinfo=Timezone('UTC')) ``` The result of parsing therefore depends on *when* and *where* you parse, not only on the input. For user-controlled or externally supplied strings, that dependence is a correctness and security concern. :::{admonition} How `whenever` does it :class: tip [Parsing](guide/parsing.md) in `whenever` never consults the clock, and each method parses one format into one type: a string that doesn't fully determine that type is a `ValueError`. ::: (pendulum-bugs)= ## Other notable bugs This section highlights some of the more consequential defects in Pendulum. Unlike the design decisions above, none needs a redesign or breaks compatibility, and several already have a proposed fix. | | Issue | Open since | Proposed fix | |---|---|---|---| | Values carrying a non-Pendulum `tzinfo` are treated as naive | [#527](https://github.com/python-pendulum/pendulum/issues/527), [#646](https://github.com/python-pendulum/pendulum/issues/646) | Dec 2020 | — | | A `dateutil` zone passed as `tz=` becomes `+00:00` | — | unreported | — | | `Duration / timedelta`, `//`, `%`, and `divmod()` raise `AttributeError` | [#382](https://github.com/python-pendulum/pendulum/issues/382) | Jun 2019 | [#1042](https://github.com/python-pendulum/pendulum/pull/1042) | | `//`, `%`, and `divmod()` ignore calendar units | [#799](https://github.com/python-pendulum/pendulum/issues/799) | Jan 2024 | [#1051](https://github.com/python-pendulum/pendulum/pull/1051) | | Durations lose microseconds beyond ~136 years | [#332](https://github.com/python-pendulum/pendulum/issues/332) | Jan 2019 | [#1003](https://github.com/python-pendulum/pendulum/pull/1003) | | `Time` arithmetic drops the time zone and microseconds | [#362](https://github.com/python-pendulum/pendulum/issues/362), [#584](https://github.com/python-pendulum/pendulum/issues/584) | Apr 2019 | — | | A naive value's `is_dst()` is `True` while its `dst()` is `None` | — | unreported | — | | `is_future()` and `diff_for_humans()` raise `TypeError` on naive values | — | unreported | — | | `Interval.in_days()` counts local calendar dates, not 24-hour periods | — | unreported | — | | A negative `Interval` contains neither endpoint, yet `range()` iterates it | — | unreported | — | | Pickling and `copy.copy` drop `fold` | [#908](https://github.com/python-pendulum/pendulum/issues/908) | Aug 2025 | [#909](https://github.com/python-pendulum/pendulum/pull/909) | | `precise_diff` ignores the end's time of day | [#906](https://github.com/python-pendulum/pendulum/issues/906) | Aug 2025 | [#953](https://github.com/python-pendulum/pendulum/pull/953) | | `precise_diff` month detection is not monotonic — 29 days is "1 month", 30 days is "4 weeks 2 days" | [#790](https://github.com/python-pendulum/pendulum/issues/790) | Jan 2024 | [#1043](https://github.com/python-pendulum/pendulum/pull/1043) | | The two builds disagree on ISO 8601 durations | [#534](https://github.com/python-pendulum/pendulum/issues/534), [#833](https://github.com/python-pendulum/pendulum/issues/833) | Feb 2021 | [#993](https://github.com/python-pendulum/pendulum/pull/993), [#1005](https://github.com/python-pendulum/pendulum/pull/1005) | | No parity tests between the two builds | [#907](https://github.com/python-pendulum/pendulum/issues/907) | Aug 2025 | — | | `from_format(..., "X", tz=...)` returns the wrong instant | — | unreported | — | | Out-of-range offsets parse, then fail on use | — | unreported | [#1015](https://github.com/python-pendulum/pendulum/pull/1015) (pure-Python build only) | | Offsets in time-only strings are silently discarded | — | unreported | — | | Formatter tokens are recognized but not implemented | — | unreported | — | | Locale data is wrong or missing in ~15 locales | — | unreported | — | | `TZ` is ignored on macOS | [#905](https://github.com/python-pendulum/pendulum/issues/905) | Jul 2025 | [#942](https://github.com/python-pendulum/pendulum/pull/942) | | The classes declare no `__slots__` | — | unreported | — | | `timezone(2)` means seconds, `datetime(tz=2)` means hours | — | unreported | — | | No API reference | [#199](https://github.com/python-pendulum/pendulum/issues/199) | May 2018 | — | (pendulum-parse-lenient)= ### Parsing accepts what it cannot represent Guessing at missing input has a counterpart in what the parser lets through. The parser doesn't reject out-of-range offsets; the resulting value exists, prints, and fails on first use: ```python >>> d = pendulum.parse("2024-01-01T10:00-99:00") >>> d DateTime(2024, 1, 1, 10, 0, 0, tzinfo=FixedTimezone(-356400, name="-99:00")) >>> d.isoformat() Traceback (most recent call last): ... ValueError: offset must be a timedelta strictly between -timedelta(hours=24) and timedelta(hours=24), ... ``` ### Format tokens and locale data are unreliable The token formatter substitutes every letter that happens to be a token, whether or not it was meant as one. Other tokens are recognized but not implemented, and come out as themselves: ```python >>> d = pendulum.datetime(2024, 1, 15, 12) >>> d.format("Today is dddd") 'To1ay i0 Monday' >>> d.format("GGGG-[W]WW-E") # ISO week date 'GGGG-WWW-1' >>> d.format("h:mm a"), d.format("h:mm A") ('12:00 a', '12:00 PM') ``` ### `Time` arithmetic can discard information Pendulum exposes aware `Time` values, but its arithmetic converts through an epoch datetime and returns only the clock fields. The time zone is silently lost, subtraction drops microseconds ([#362](https://github.com/python-pendulum/pendulum/issues/362), [#584](https://github.com/python-pendulum/pendulum/issues/584)), and a negative `timedelta` is rejected because of `timedelta`'s own normalization of days and seconds: ```python >>> paris = pendulum.timezone("Europe/Paris") >>> pendulum.Time(12, tzinfo=paris).add(hours=1) Time(13, 0, 0) >>> pendulum.Time(12, 0, 0, 900_000) - pendulum.Time(12, 0, 0, 100_000) Duration() >>> pendulum.time(12) + timedelta(hours=-1) Traceback (most recent call last): ... TypeError: Cannot add timedelta with days to Time. ``` ### Durations lose precision past a century The internal representation is a `float` of total seconds, so microseconds are lost for spans beyond roughly 136 years, even though `==` still says the values are equal ([#332](https://github.com/python-pendulum/pendulum/issues/332), open since 2019): ```python >>> pendulum.duration(days=100000, microseconds=1).microseconds 2 >>> pendulum.datetime(1, 1, 1) + (pendulum.DateTime.max - pendulum.DateTime.min) DateTime(9999, 12, 31, 23, 59, 0, tzinfo=Timezone('UTC')) # 59.999999 s short ``` (pendulum-foreign-tzinfo)= ### Time zones Pendulum didn't create Pendulum's methods work through `DateTime.tz`, which is `None` for any `tzinfo` that isn't Pendulum's own. Such values are easy to come by: `astimezone()` with no argument or with a `ZoneInfo`, `replace(tzinfo=...)`, or the constructor. Pendulum's methods then treat them as naive, sometimes after shifting the fields to UTC: ```python >>> from datetime import timezone, timedelta >>> d = pendulum.DateTime(2024, 1, 1, 12, tzinfo=timezone(timedelta(hours=5))) >>> d.add(hours=1) DateTime(2024, 1, 1, 8, 0, 0) # naive; 13:00+05:00 expected >>> import copy >>> copy.deepcopy(pendulum.now("UTC").astimezone(ZoneInfo("Europe/Paris"))).tzinfo is None True ``` Pendulum quietly turns a `dateutil` zone passed as `tz=` into a fixed offset or UTC: ```python >>> from dateutil import tz >>> pendulum.datetime(2024, 7, 1, 12, tz=tz.gettz("Europe/Paris")) DateTime(2024, 7, 1, 12, 0, 0, tzinfo=FixedTimezone(0, name="+00:00")) ``` The `tz` parameter is lenient in one further direction: it reads a bare number as *hours*, while `pendulum.timezone()` reads the same number as *seconds*: ```python >>> pendulum.datetime(2024, 1, 1, tz=2) DateTime(2024, 1, 1, 0, 0, 0, tzinfo=FixedTimezone(7200, name="+02:00")) >>> pendulum.timezone(2) FixedTimezone(2, name="+00:00") # two seconds; name truncated ``` (pendulum-serialization)= ### Serialization can change the instant Pickling doesn't preserve `fold`. Serializing a datetime in the second occurrence of a repeated hour and reading it back can therefore change its timestamp: ```python >>> original = pendulum.datetime(2024, 11, 3, 1, tz="America/Chicago", fold=1) >>> restored = pickle.loads(pickle.dumps(original)) >>> original.fold, restored.fold (1, 0) >>> original.timestamp() == restored.timestamp() False ``` (pendulum-slots)= ### The classes carry a `__dict__` None of Pendulum's classes declares `__slots__`, so every instance carries a dictionary the standard library's doesn't. A `DateTime` accepts a mistyped attribute in silence: ```python >>> d = pendulum.datetime(2024, 1, 1) >>> d.yeaar = 3000 # no error >>> d.__dict__ {'yeaar': 3000} ``` (pendulum-global-state)= ### Global settings change behavior at a distance Locale, week start, and the system time zone are process-wide settings, so the result of a call depends on state the call doesn't mention — set by an unrelated part of the program, or by a library it happens to import. - `week_starts_at()` and `week_ends_at()` are independent; setting only the first gives one-day and eight-day weeks (`pendulum.week_starts_at(SUNDAY)`; a Sunday's `start_of("week")` and `end_of("week")` are both itself), and `week_of_month` and `week_of_year` ignore the setting. - `set_locale()` is process-global and, as shown below, reaches the RFC formats. (pendulum-formatting)= ### The global locale reaches formats that mandate English The formatter and its locale data are one of Pendulum's attractions, and they mostly work. But they are also where the global locale is most visible: the standard formats follow it, although RFC 2822 and friends mandate English. ```python >>> pendulum.set_locale("fr") >>> pendulum.datetime(2024, 1, 15, 12).to_rfc2822_string() 'lun., 15 janv. 2024 12:00:00 +0000' >>> pendulum.datetime(2024, 1, 15, 12).to_cookie_string() # only this one is pinned 'Monday, 15-Jan-2024 12:00:00 UTC' >>> pendulum.set_locale("en") ``` (pendulum-two-impls)= ### Two implementations that disagree Pendulum ships a Rust extension and a pure-Python fallback (`PENDULUM_EXTENSIONS=0`). Offering both is a good idea (`whenever` does the same) but only if they agree. The project lacks systematic parity tests between them, as acknowledged in [#907](https://github.com/python-pendulum/pendulum/issues/907), and they diverge in both parsing and arithmetic. The compiled `precise_diff` checks the *exact* type of its second argument, so when an `Interval`'s end was a standard-library datetime (which Pendulum wraps in a subclass), the diff treats the end's time of day as midnight: ```python >>> a = pendulum.datetime(2024, 1, 1) >>> b = datetime(2024, 1, 1, 5, 30, tzinfo=timezone.utc) >>> a.diff(b).in_seconds(), a.diff(b).hours, a.diff(b).in_words() (19800, 0, '0 microseconds') >>> a.diff_for_humans(b) 'a few seconds before' >>> pendulum.interval(datetime(2025, 7, 25, 19, 26, 34), ... datetime(2025, 7, 29, 19, 26, 34)).in_words() '3 days 4 hours 33 minutes' ``` The pure-Python build answers `5 hours 30 minutes` and `'4 days'`. That bug is [#906](https://github.com/python-pendulum/pendulum/issues/906), and the check is unchanged on `master`. The two parsers can't agree on valid and invalid input. On the compiled build `PT4294967297M` overflows to `Duration(minutes=1)`, `P12M4M` and `PT1H1H` are accepted, `P4294967296D` is `Duration()`, fractional durations are rounded to whole minutes (`P0.001D` → `Duration(minutes=1)`), and `T12:30:00` is rejected. The pure-Python parser preserves all 4,294,967,297 minutes and rejects the duplicates, but interprets every fractional component as tenths regardless of the number of digits ([#534](https://github.com/python-pendulum/pendulum/issues/534), open since 2021) and accepts many strings that aren't ISO 8601: ```text # Compiled parser # Pure-Python parser >>> pendulum.parse("P1.25D") >>> pendulum.parse("P1.25D") Duration(days=1, hours=6) Duration(days=3, hours=12) >>> pendulum.parse("PT1.25H") >>> pendulum.parse("PT1.25H") Duration(hours=1, minutes=15) Duration(hours=3, minutes=30) >>> pendulum.parse("202401") >>> pendulum.parse("202401") ParserError DateTime(2026, 8, 23, 20, 24, 1, tzinfo=Timezone('UTC')) ``` (pendulum-project)= ## The state of the project The problems on this page come in two kinds, and neither has a quick fix. The design decisions would take a breaking redesign. The bugs would take scarce maintainer time. ### The documentation is outdated and incomplete Pendulum's documentation is primarily a guide, rather than a complete API reference. A request for a usable reference has remained open since 2018 ([#199](https://github.com/python-pendulum/pendulum/issues/199)). Several published examples no longer match version 3.3.0: - the time zone guide still recommends `dst_rule`, `PRE_TRANSITION`, `POST_TRANSITION`, and `TRANSITION_ERROR`, all removed in 3.0 ([#789](https://github.com/python-pendulum/pendulum/issues/789); a fix is proposed in [#1053](https://github.com/python-pendulum/pendulum/pull/1053)); - parts of the documentation still call the result of `diff()` a `Period`, although the public class is now `Interval`; - examples for `Duration.total_days()` disagree with the actual result; - the introduction claims comparisons account for time zones; the repeated-hour example above shows otherwise. ### Maintenance remains a concern Pendulum's original author wrote nearly all of the library. Its last major release, 3.0 (December 2023), broke compatibility: it renamed the `Period` class, removed the transition-rule API, and replaced the C extension with a Rust one. Development slowed after that release. Since 2025 the project lives under the `python-pendulum` organization. Its new maintainers picked up a codebase they didn't write, and have kept it going: 3.1 shipped in April 2025, 3.2 in January 2026, and 3.3 in October 2026, with a batch of fixes. Their options are limited: most of what this page describes follows from the design decisions listed at the top, which cannot be changed without breaking the "drop-in replacement" promise that is Pendulum's main selling point. None of those decisions were theirs. Reproducible correctness issues remain open across releases, including some with a tested fix ready ([#909](https://github.com/python-pendulum/pendulum/pull/909), [#968](https://github.com/python-pendulum/pendulum/pull/968), [#975](https://github.com/python-pendulum/pendulum/pull/975), [#987](https://github.com/python-pendulum/pendulum/pull/987)). ## In short Pendulum's problems follow from its design. A subclass can't fix `datetime` and stay a drop-in replacement. And guessing (at missing time zones, at incomplete input, at which kind of arithmetic a call means) can't be patched into correctness. Reversing any of these decisions would break the code that depends on them. `whenever` makes the opposite trade: there's no drop-in path. In return, you decide what each value in your program actually is, and its type holds you to it. Nothing is guessed silently. [^versions]: This page is up to date as of Pendulum 3.3.0. Every example was run against that version on CPython 3.14, unless stated otherwise.