Posted on :: Min Read

Date and time handling in software has always been a pain point — because date-time math and timezones are hard! — and Python has has historically made some ill-advised choices in the past that catch even the most seasoned developers off-guard.

Author's Note

This post has generated quite a lot of feedback, and I have amended some of the examples and explanations below to better reflect the pitfalls that can arise. A few corrections have been made as well. Thanks to all those that wrote in! I really do appreciate it.

Naive Date-Time Objects Are a Menace

The biggest culprit in date-time related bugs has and always will be that Python allows the existence of timezone-naive date-time objects. If there's one thing you should be afraid of when dealing with dates, it's definitely timezone-naive dates.

Aware vs. Naive

A timezone-aware date-time object is one where a moment in time is paired with a timezone identifier; this is (most of the time) enough information to disambiguate one date-time from another, and to perform relative calculations between two or more timezone-aware date-time objects.

A timezone-naive date-time object is an object that contains a date and a time, but eschews any pretense of communicating an associated timezone. Which makes the whole thing completely useless.

It's like talking about distance without a unit: the only reason it kinda-sorta-sometimes works out is because you assume that everyone in the room is using the same system. Start mixing in folks that use kilometres with those that use miles, however, and you're going to end up crashing a very expensive spacecraft on the surface of Mars.

Epoch Timestamps

Epoch timestamps are, generally, a decent way to perform relative comparisons between two moments in time that satisfy the two following conditions:

  1. Occurred after January 1st 1970 (and most likely before 2038).
  2. Leap seconds don't matter1.

With epochs, you can get the number of seconds between two events. What you do with those seconds afterwards is trickier (e.g. converting seconds to days/weeks/months/years), but the delta of integer seconds between those events is at least in the right ballpark to be useful.

In Python, the simplest way to get an epoch timestamp is by using the time module2:

>>> import time
>>> print(int(time.time())
1721069814

But it doesn't matter which language or operating system you use: pretty much everyone has agreed on what an epoch timestamp is, and we've also agreed on how to represent them. Standards! Sometimes they work.

Converting from Timestamps to datetime Objects

The zero-time for epoch is actually timezone-dependent since it is defined as the number of non-leap seconds that have elapsed since 1970-01-01T00:00:00 UTC.

But, it's just a counter! It doesn't matter where on the earth the computer (or very dedicated human) who generated the timestamp was in3.

The Python library actually calls the underlying time function from the standard C library, which (typically) implements the POSIX standard:

>>> import time
>>> import datetime
>>> datetime.datetime.fromtimestamp(time.time())
datetime.datetime(2024, 7, 15, 17, 10, 35, 561153)

The value returned by time.time() is, as I previously mentioned, relative to epoch (UTC), which does what you expect.

But datetime.datetime.now(), which is what many Python developers will reach for when needing the current date-time, returns a timezone-naive object that is in the timezone of your local system. This means that if you generate a timestamp from datetime.datetime.now().timestamp(), you may be in for a rough time.

If your operating system/container/whatever is set to use UTC as the local timezone, then you're probably okay. But when you run this code on a system whose timezone is set to a non-UTC timezone, then the result of datetime.datetime.fromtimestamp() will be a timezone-naive date-time object that has been converted to the system-local timezone!

Here's a more concrete example3, where my timezone is set to America/Toronto:

$ sudo systemsetup -gettimezone
Time Zone: America/Toronto
>>> import time
>>> import datetime
>>> now = datetime.datetime.now(); utcnow = datetime.datetime.utcnow()
>>> print(now)
datetime.datetime(2024, 7, 17, 11, 7, 32, 189510)
>>> print(utcnow)
datetime.datetime(2024, 7, 17, 15, 7, 32, 189532)

And if you try to convert the timestamp back to a date-time object:

>>> datetime.datetime.fromtimestamp(now.timestamp())
datetime.datetime(2024, 7, 17, 11, 7, 32, 189510)
>>> datetime.datetime.fromtimestamp(utcnow.timestamp())
datetime.datetime(2024, 7, 17, 15, 7, 32, 189532)

We get different results! This should not be surprising, since datetime.datetime.now() and datetime.datetime.utcnow() will produce different naive date-time objects if your local system time is not set to UTC.

This could have all been avoided had we assigned a timezone to those date-times in the first place. Be vigilant: Consider any timezone-naive object a menace to you and your sanity, and ensure that they don't infect your code.


1

If for some reason leap seconds do matter to you, there are conversion tables available in the IANA time zone database, but if you have to do that you've likely done something terribly wrong.

2

The return value of time.time() is actually a floating-point number, but not all systems actually provide time values with a better precision than 1 second. For the sake of simplicity in the discussion, we'll just use integers, anyways. It's also not guaranteed to be monotonically increasing, which is a whole other thing that can trip people up, but that will have to be a separate blog post.

3

I'm cheating a little bit, here, for the sake of exposition. Python has long since deprecated the datetime.datetime.utcnow() function, because it produces a naive object that is ostensibly in UTC. The modern recommendation is to use datetime.datetime.now(datetime.timezone.utc), which produces a timezone-aware object. I needed a way to generate a naive date-time in UTC that did not have an attached timezone without going through multiple steps, since this is already confusing enough.