Skip to content

Instantly share code, notes, and snippets.

@codeinthehole
Last active March 9, 2021 21:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save codeinthehole/1ac10da7874033406f25f86df07b88ff to your computer and use it in GitHub Desktop.
Save codeinthehole/1ac10da7874033406f25f86df07b88ff to your computer and use it in GitHub Desktop.
A Python unit test that demonstrates the problem with Django's `make_aware` function
import datetime
import pytz
from django.utils import timezone
from dateutil import tz
# This test passes.
def test_pytz_vs_dateutil_timezones():
timezone_name = "Europe/London"
# Start with a naive dt.
start_dt_naive = datetime.datetime(2021, 3, 1)
# Create an aware dt using Django's make_aware fn (which attaches a pytz time zone instance). We pass the timezone
# in explicitly here. If no timezone was passed, the TIME_ZONE setting would be used instead. See
# implementation of make_aware:
# https://github.com/django/django/blob/76c0b32f826469320c59709d31e2f2126dd7c505/django/utils/timezone.py#L233-L246
start_dt_pytz = timezone.make_aware(start_dt_naive, timezone=pytz.timezone(timezone_name))
# Create an aware dt using dateutil's time zone object.
# https://dateutil.readthedocs.io/en/stable/tz.html#dateutil.tz.gettz
start_dt_dateutil = start_dt_naive.replace(tzinfo=tz.gettz(timezone_name))
# These two datetimes represent the same point in time.
assert start_dt_pytz == start_dt_dateutil
# Create two new datetimes by adding the same offset to each aware datetime. Crucially,
# this new datetime is the other side of the UK spring DST transition date.
delta = datetime.timedelta(days=45)
end_dt_pytz = start_dt_pytz + delta
end_dt_dateutil = start_dt_dateutil + delta
# And now the two dts are not equal to each other (!) due to the way that their tzinfo objects are
# implemented.
assert end_dt_pytz != end_dt_dateutil
# The pytz-based dt doesn't account for the DST offset changing when the delta is added and
# so ends up incrementing the time to 01:00 due to the additional hour. It has naively added 45 * 24 hours
# to the start_dt (which might be what you want but is perhaps a bit counterintuitive).
assert end_dt_pytz.isoformat() == "2021-04-15T00:00:00+00:00"
assert end_dt_pytz == timezone.make_aware(
datetime.datetime(2021, 4, 15, 1), timezone=pytz.timezone(timezone_name)
)
# The dateutil-based dt handles the DST offset and the new datetime ends at
# midnight as expected (but the delta is < 45 * 24 hours).
assert end_dt_dateutil.isoformat() == "2021-04-15T00:00:00+01:00"
# Note, adding timedelta(days=n) to a datetime is a weird thing to do. For many real-world problems,
# it's more appropriate to perform calculations with *dates*, then combine with a datetime.time to get a datetime.
# Indeed, I would consider adding a timedelta of days to a datetime something of a code smell: not wrong per se,
# but worth checking it's really the most appropriate way.
# Want to know more? See https://blog.ganssle.io/articles/2018/03/pytz-fastest-footgun.html
# Also see discussion on tweet: https://twitter.com/codeinthehole/status/1369349761799757826
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment