Why not Pendulum?¶
Pendulum is a popular third-party datetime library that arrived on the scene in 2016, promising better DST handling. It offers a convenient API, useful formatting and localization features, and more intuitive arithmetic than the standard library in several common cases.
However, Pendulum is best understood as an extension of datetime,
not a redesign of it. Because it subclasses datetime and timedelta,
it inherits their shortcomings along with their compatibility.
It then adds calendar units and implicit policies that these base classes
cannot always preserve consistently.
As a result, equality, hashing, arithmetic, parsing, and serialization can
disagree about what a value means.
Note
This section is up to date as of Pendulum version 3.2.0.
It improves ergonomics, but retains datetime’s model¶
Pendulum does address some of the standard library’s
pitfalls.
In particular, its named arithmetic methods distinguish calendar units from
elapsed units across DST transitions, and Duration.seconds avoids the
timedelta.seconds footgun.
Most of the underlying model remains unchanged, though:
naive and aware datetimes use the same class;
DateTimeinherits fromdate;equality and ordering retain
datetime’s DST edge cases.
Pendulum’s description of its classes as “drop-in replacements” therefore needs qualification. The same documentation acknowledges a non-exhaustive list of incompatibilities with libraries that distinguish types exactly, including database drivers.
Pendulum’s policy of preferring aware datetimes is not consistently enforced throughout the API:
>>> import pendulum
>>> pendulum.datetime(2020, 1, 1)
DateTime(2020, 1, 1, 0, 0, 0, tzinfo=Timezone('UTC'))
>>> pendulum.DateTime(2020, 1, 1)
DateTime(2020, 1, 1, 0, 0, 0)
Some inherited states are not handled consistently either.
For example, a naive DateTime reports that it is in daylight saving time,
even though dst() correctly reports that DST is undefined:
>>> naive = pendulum.DateTime(2024, 1, 1)
>>> naive.dst() is None
True
>>> naive.is_dst()
True
Missing timezone information becomes UTC¶
When parsing a datetime without an offset, Pendulum assumes UTC:
>>> import pendulum
>>> pendulum.parse("2024-03-10T15:00")
DateTime(2024, 3, 10, 15, 0, 0, tzinfo=Timezone('UTC'))
An ISO 8601 datetime without an offset does not identify an instant. It only supplies local date and time fields. Treating it as UTC invents information that was absent from the input. If the source intended another time zone, this can silently shift the resulting instant by several hours.
The same policy is used when converting a naive standard-library datetime:
>>> from datetime import datetime
>>> pendulum.instance(datetime(2024, 3, 10, 15))
DateTime(2024, 3, 10, 15, 0, 0, tzinfo=Timezone('UTC'))
Calendar arithmetic violates basic invariants¶
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.
At the same time, applying a month to a datetime uses calendar arithmetic.
Consequently, values that compare equal are not interchangeable:
>>> import pendulum
>>> month = pendulum.duration(months=1)
>>> thirty_days = pendulum.duration(days=30)
>>> month == thirty_days
True
>>> hash(month) == hash(thirty_days)
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'))
Even identity operations can erase calendar information:
>>> month + pendulum.duration()
Duration(weeks=4, days=2)
>>> month * 1
Duration(months=1)
>>> month * 1.0
Duration()
So can ordinary serialization and shallow copying:
>>> import copy
>>> import pickle
>>> copy.copy(month)
Duration(weeks=4, days=2)
>>> pickle.loads(pickle.dumps(month))
Duration(weeks=4, days=2)
Compatibility with the timedelta base class is also asymmetric:
>>> from datetime import timedelta
>>> day = pendulum.duration(days=1)
>>> timedelta(hours=1) / day
0.041666666666666664
>>> day / timedelta(hours=1)
Traceback (most recent call last):
...
AttributeError: 'datetime.timedelta' object has no attribute '_to_microseconds'
The same private-method assumption affects floor division, modulo, and
divmod(). This has been reported for Interval since 2019
(#382).
Interval violates equality and hashing contracts¶
Interval, the result of subtracting two Pendulum datetimes,
is itself a subclass of Duration.
Its equality semantics do not form a valid equivalence relation:
>>> jan = pendulum.interval(
... pendulum.datetime(2024, 1, 1),
... pendulum.datetime(2024, 1, 2),
... )
>>> one_day = pendulum.duration(days=1)
>>> feb = pendulum.interval(
... pendulum.datetime(2024, 2, 1),
... pendulum.datetime(2024, 2, 2),
... )
>>> jan == one_day
True
>>> one_day == feb
True
>>> jan == feb
False
Equality is therefore not transitive. Equal objects can also have different hashes:
>>> jan == one_day
True
>>> hash(jan) == hash(one_day)
False
>>> len({jan, one_day})
2
This violates Python’s contract for hashable objects and can produce incorrect behavior in dictionaries, sets, caches, and deduplication code.
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 timezone is silently
lost:
>>> paris = pendulum.timezone("Europe/Paris")
>>> noon = pendulum.Time(12, tzinfo=paris)
>>> noon.add(hours=1)
Time(13, 0, 0)
>>> noon.add(hours=1).tzinfo is None
True
Subtracting two times has a separate precision problem:
>>> late = pendulum.Time(12, 0, 0, 900_000)
>>> early = pendulum.Time(12, 0, 0, 100_000)
>>> late - early
Duration()
The expected difference is 800,000 microseconds. The implementation calculates whole seconds and omits both operands’ microseconds, an issue reported in #362 and #584.
DST handling remains inconsistent¶
Pendulum’s named add() and subtract() methods are a useful
improvement over datetime.
They distinguish calendar days from elapsed hours and handle many DST
transitions conveniently.
Equivalent-looking operations do not always take the same path:
>>> import pendulum
>>> 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'))
Here, dt - delta differs from both dt.subtract(...) and dt + -delta.
A fix is proposed in
pull request #987.
Comparisons can contradict chronological order¶
During a repeated hour, ordering compares local fields instead of instants:
>>> 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.timestamp() > earlier.timestamp()
True
>>> later < earlier
True
This contradicts Pendulum’s documentation, which says comparisons account for time zones. The problem was first reported in #351; another fix is currently proposed in #985.
Intervals spanning the repeated hour are internally inconsistent as well. For an interval of exactly one elapsed hour, Pendulum 3.2.0 can report:
>>> first = pendulum.datetime(
... 2023, 11, 5, 1, 25,
... tz="America/Los_Angeles",
... fold=0,
... )
>>> second = pendulum.datetime(
... 2023, 11, 5, 1, 25,
... tz="America/Los_Angeles",
... fold=1,
... )
>>> interval = second - first
>>> interval.total_seconds()
3600.0
>>> interval.in_seconds()
3600
>>> interval.hours
0
>>> interval.in_words()
'0 microseconds'
Serialization can change the instant¶
Pickling does not preserve fold.
Serializing a datetime in the second occurrence of a repeated hour
and reading it back can therefore change its timestamp:
>>> import pickle
>>> 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
Shallow copying follows the same reconstruction path and has the same effect;
deepcopy() does preserve the fold.
This is tracked in #908.
An open fix is available in
pull request #909.
Its default disambiguation differs from the common convention¶
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 offset before the transition; see
the discussion of ambiguity defaults.
Pendulum allows callers to choose a fold, and
raise_on_unknown_times=True can reject ambiguous or nonexistent local times.
The default is significant: moving code from datetime to Pendulum can change
which instant an ambiguous local time represents unless the fold is chosen
explicitly.
Parsing is permissive and implementation-dependent¶
parse() handles several unrelated kinds of input.
Depending on the string, it may return a DateTime, Duration, or Interval.
Time-only input is particularly surprising:
>>> type(pendulum.parse("12:34:56")).__name__
'DateTime'
>>> type(pendulum.parse("12:34:56", exact=True)).__name__
'Time'
Without exact=True, Pendulum supplies the current date.
The same parser also gives the string "now" special, undocumented,
time-dependent behavior:
>>> pendulum.parse("now").timezone_name
'UTC'
This makes the result depend on the wall clock rather than only on the input. It can therefore be risky when parsing user-controlled or externally supplied strings.
Parsing behavior also depends on whether Pendulum’s Rust extension is loaded. On the normal compiled build:
>>> pendulum.parse("PT4294967297M")
Duration(minutes=1)
>>> pendulum.parse("P12M4M")
Duration(months=4)
The first value has overflowed, while the second is not a valid ISO 8601
duration.
With PENDULUM_EXTENSIONS=0, the pure-Python parser preserves all
4,294,967,297 minutes and rejects the duplicated month component.
The implementations can also assign different values to valid input:
# Compiled parser
>>> pendulum.parse("P1.25D")
Duration(days=1, hours=6)
# Pure-Python parser
>>> pendulum.parse("P1.25D")
Duration(days=3, hours=12)
The pure parser divides the fractional digits by ten regardless of their
length, so 1.25 days is interpreted as 3.5 days. This parser bug has
remained open since 2021
(#534).
The project currently lacks systematic parity tests between the two
implementations, as acknowledged in
#907.
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).
Several published examples no longer match version 3.2.0:
the timezone guide still recommends
dst_rule,PRE_TRANSITION,POST_TRANSITION, andTRANSITION_ERROR, all removed in 3.0 (#789);parts of the documentation still call the result of
diff()aPeriod, although the public class is nowInterval;examples for
Duration.total_days()disagree with the actual result;the introduction says that comparisons account for time zones, which the repeated-hour example above contradicts.
Maintenance remains a concern¶
Pendulum is active again after a long period of limited maintenance, and recent
releases and contributions are welcome signs.
However, reproducible correctness issues remain unresolved across releases,
including cases where a tested fix is already available.
Examples include the pending fixes for pickling, naive datetime differences,
floating-point Duration multiplication, fold ordering, and DST subtraction
(#909,
#968,
#975,
#985,
and #987).
Performance has regressed¶
While Pendulum initially promised
improved performance,
users have reported a version 3
in_tz() performance regression.
In benchmarks, Pendulum is often an order of magnitude
slower than both the standard library and whenever.
Correctness and predictable semantics matter more than speed for datetime code. The additional overhead may nevertheless matter to applications performing large volumes of datetime operations.