Developer Tools

Python datetime Done Right: Timezones, zoneinfo, and Common Pitfalls

Python's datetime module is powerful but full of traps. Naive datetimes, pytz vs zoneinfo, and silent timezone bugs can ruin your day. Here's the guide I wish I'd had.

VR
Vikram Rao

Senior Software Engineer

2026年1月29日·9 分で読める

Naive vs Aware: The #1 Source of Bugs

Python datetimes come in two flavors: naive (no timezone info) and aware (has timezone info). The dangerous part is that Python happily lets you create naive datetimes and then silently does the wrong thing when you compare or subtract them.

from datetime import datetime

# Naive — no timezone, looks innocent
dt = datetime(2026, 3, 15, 14, 30)

# Aware — explicit timezone
from zoneinfo import ZoneInfo
dt_aware = datetime(2026, 3, 15, 14, 30, tzinfo=ZoneInfo("America/New_York"))

Comparing a naive and aware datetime raises TypeError. Comparing two naive datetimes from different real-world timezones? That just gives you a wrong answer silently. I've debugged production outages caused by exactly this.

Here's the insidious pattern: your app creates naive datetimes from database reads (because most ORMs strip timezone info by default), and you compare those against datetime.now() (also naive, in the server's local time). This works perfectly in development. Then you deploy to a server in UTC and everything is off by 5 hours. Or you deploy to AWS in us-east-1 (UTC) and your cron jobs fire at the wrong time. I've seen this exact scenario at three different companies.

zoneinfo vs pytz: Use zoneinfo

Before Python 3.9, pytz was the only real option for timezone handling. It worked, but with a bizarre API: you had to use localize() instead of passing tzinfo directly, because pytz zones weren't compatible with the standard datetime constructor.

# pytz — the OLD way (still works but don't use it)
import pytz
tz = pytz.timezone("US/Eastern")
dt = tz.localize(datetime(2026, 3, 15, 14, 30))  # Correct
dt = datetime(2026, 3, 15, 14, 30, tzinfo=tz)     # WRONG! Uses LMT offset

# zoneinfo — the RIGHT way (Python 3.9+)
from zoneinfo import ZoneInfo
dt = datetime(2026, 3, 15, 14, 30, tzinfo=ZoneInfo("America/New_York"))  # Just works

The pytz constructor bug is infamous: datetime(2026, 1, 1, tzinfo=pytz.timezone("US/Eastern")) gives you an offset of -04:56 instead of -05:00 because it uses the zone's historical LMT offset from the 1800s. This has bitten basically everyone at least once.

If you're on Python 3.9+, use zoneinfo. Period. For older versions, use backports.zoneinfo.

One thing to watch out for with zoneinfo: it relies on the system's IANA timezone data. On Linux, this is usually the tzdata package. On macOS, it's bundled with the OS. But on Windows, the system timezone data can be stale. Python 3.9+ provides a fallback tzdata PyPI package — install it with pip install tzdata if you're targeting Windows or if you want consistent data across platforms. In Docker containers using slim images, the timezone data might not be installed at all, which will cause zoneinfo to fail silently or raise ZoneInfoNotFoundError.

Converting Between Timezones

from datetime import datetime
from zoneinfo import ZoneInfo

# Create a time in New York
ny_time = datetime(2026, 3, 15, 14, 30, tzinfo=ZoneInfo("America/New_York"))

# Convert to Tokyo
tokyo_time = ny_time.astimezone(ZoneInfo("Asia/Tokyo"))
print(tokyo_time)  # 2026-03-16 03:30:00+09:00

# Convert to UTC
utc_time = ny_time.astimezone(ZoneInfo("UTC"))
print(utc_time)  # 2026-03-15 18:30:00+00:00

The astimezone() method is the right tool. It returns a new datetime representing the same instant in time, but expressed in the target zone.

A gotcha with astimezone(): if you call it on a naive datetime, Python doesn't raise an error. Instead, it assumes the naive datetime is in the system's local timezone and converts from there. This is almost never what you want, and it makes the bug timezone-dependent — it "works" if your development machine and server happen to be in the same timezone, then breaks mysteriously in production. Always make sure the source datetime is aware before calling astimezone().

timedelta and Date Arithmetic

from datetime import timedelta

now = datetime.now(ZoneInfo("UTC"))
in_two_weeks = now + timedelta(weeks=2)
thirty_days_ago = now - timedelta(days=30)

# Difference between two datetimes
delta = in_two_weeks - now
print(delta.days)          # 14
print(delta.total_seconds())  # 1209600.0

Be careful with timedelta around DST transitions. Adding timedelta(days=1) adds exactly 24 hours, which might not land on the same clock time if a DST change happened. For "same time tomorrow," use replace(day=dt.day+1) or dateutil.relativedelta.

The relativedelta from python-dateutil is worth learning if you do any calendar-relative math. Unlike timedelta (which only understands fixed durations — days, seconds, microseconds), relativedelta understands months and years:

from dateutil.relativedelta import relativedelta

dt = datetime(2026, 1, 31, tzinfo=ZoneInfo("UTC"))
one_month_later = dt + relativedelta(months=1)
print(one_month_later)  # 2026-02-28 — rolls back to last day of Feb

# timedelta can't do this:
# dt + timedelta(months=1)  # TypeError — no 'months' argument

The question "what's one month from January 31?" doesn't have a single right answer, but relativedelta picks the most intuitive one: the last day of February. It's a small library, but it fills a gap that the standard library should have covered years ago.

Formatting and Parsing

# Format to string
dt.strftime("%Y-%m-%d %H:%M:%S %Z")  # "2026-03-15 14:30:00 EDT"

# Parse from string
dt = datetime.strptime("2026-03-15 14:30", "%Y-%m-%d %H:%M")  # Returns naive!
dt = dt.replace(tzinfo=ZoneInfo("America/New_York"))           # Make it aware

# ISO format (preferred)
dt.isoformat()  # "2026-03-15T14:30:00-04:00"
datetime.fromisoformat("2026-03-15T14:30:00-04:00")  # Python 3.11+

Note that strptime always returns a naive datetime, even if the format string includes %Z. You need to attach the timezone yourself. Python 3.11 improved fromisoformat() to handle timezone offsets properly.

A word on %Z in strptime: it's almost useless. It can parse "UTC" and your platform's local timezone abbreviation, but it can't reliably parse things like "EST" or "PDT" because those abbreviations are ambiguous (as I mentioned — "CST" could be three different timezones). If you're receiving timestamps with timezone abbreviations from an external system, your best bet is to map the abbreviation to an IANA zone yourself and attach it with replace(tzinfo=...). Annoying? Yes. But there's no shortcut that's actually reliable.

The datetime.now() vs datetime.utcnow() Trap

This one catches so many people that it deserves its own section. Python has two ways to get the current time that look almost identical but behave very differently:

# This returns a NAIVE datetime in the local timezone
datetime.now()

# This returns a NAIVE datetime with UTC values — but no timezone info!
datetime.utcnow()  # DEPRECATED in Python 3.12

# This is what you actually want
datetime.now(ZoneInfo("UTC"))  # Aware datetime in UTC

The problem with utcnow() is subtle and devastating. It gives you UTC values, but the resulting datetime is naive — it has no tzinfo attached. If you later call astimezone() on it, Python assumes it's in local time and converts from there. Your UTC timestamp just got double-converted. Python 3.12 officially deprecated utcnow() for exactly this reason, but the function still works and old codebases are riddled with it.

Business Day Calculations

import numpy as np

# Count business days between two dates
start = np.datetime64("2026-01-01")
end = np.datetime64("2026-12-31")
business_days = np.busday_count(start, end)
print(business_days)  # 260 (without holidays)

# With custom holidays
holidays = [np.datetime64("2026-01-01"), np.datetime64("2026-12-25")]
business_days = np.busday_count(start, end, holidays=holidays)
print(business_days)  # 258

numpy.busday_count is fast and handles the common case well. For more complex holiday calendars, the exchange_calendars or business_calendar packages are worth looking at.

If you don't want a numpy dependency just for business day math, you can use pandas.bdate_range() — though that's an even heavier dependency. For a lightweight approach, a simple loop works fine for most cases:

from datetime import date, timedelta

def business_days_between(start: date, end: date) -> int:
    count = 0
    current = start
    while current < end:
        if current.weekday() < 5:  # Monday=0, Friday=4
            count += 1
        current += timedelta(days=1)
    return count

It's not as fast as numpy for large ranges, but it's zero-dependency and easy to extend with a holiday set. For ranges under a few years, the performance difference is negligible.

Common Pitfalls in Django and SQLAlchemy

If you're using Django, the USE_TZ = True setting (the default since Django 4.0) makes Django store aware datetimes in UTC. But templates render in the timezone set by TIME_ZONE or the user's timezone if you use django.utils.timezone.activate(). The trap: if you access .hour or .date() on a model's DateTimeField in Python code, you get the UTC values, not the user's local values. You need to call localtime() first.

SQLAlchemy has a different issue. The DateTime column type has a timezone=True parameter, but its behavior depends on the database backend. PostgreSQL's TIMESTAMP WITH TIME ZONE stores UTC and converts on retrieval. MySQL's DATETIME with timezone awareness is... complicated, and the MySQL driver may or may not strip timezone info depending on the version. If you're using SQLAlchemy with MySQL, test your timezone handling end-to-end. Don't assume.

Frequently Asked Questions

Should I use pytz or zoneinfo?

Use zoneinfo (Python 3.9+). It's in the standard library, uses the OS timezone database, and works correctly with the datetime constructor. pytz has a well-known bug where passing it as tzinfo directly gives wrong offsets. If you're stuck on Python 3.8 or earlier, use backports.zoneinfo.

What's a naive datetime?

A datetime without timezone information (tzinfo=None). It represents a "wall clock" time with no indication of which timezone it's in. Comparing or subtracting naive datetimes from different real-world timezones gives wrong results silently. Always use aware datetimes for anything that crosses timezone boundaries.

How do I calculate business days in Python?

The fastest method is numpy.busday_count(start, end, holidays=holiday_array). For simple cases without holidays, you can also loop with timedelta and check weekday() < 5. The pandas.bdate_range() function works too if you're already using pandas.

How do I get the current time in UTC in Python?

Use datetime.now(ZoneInfo("UTC")) on Python 3.9+. The older datetime.utcnow() method is deprecated because it returns a naive datetime (no timezone info), which leads to silent bugs when mixed with aware datetimes.

How do I convert a string to a datetime in Python?

Use datetime.strptime(string, format) for custom formats, e.g., datetime.strptime("2026-03-15", "%Y-%m-%d"). For ISO 8601 strings, use datetime.fromisoformat() (Python 3.7+, with full timezone offset support in 3.11+). Note that strptime always returns a naive datetime.

What is the difference between datetime and date in Python?

A date object stores only the calendar date (year, month, day) with no time component. A datetime object stores both date and time (hour, minute, second, microsecond) and can optionally include timezone information. Use date when you only need calendar dates, and datetime when you need precise timestamps.

Why does pytz give wrong timezone offsets?

Passing a pytz timezone directly to the datetime constructor (e.g., datetime(2026, 1, 1, tzinfo=pytz.timezone("US/Eastern"))) uses the zone's historical LMT offset from the 1800s instead of the current offset. You must use tz.localize(dt) instead. This is a well-known API design flaw, which is why zoneinfo (Python 3.9+) is the recommended replacement.

Sources

  • Python documentation — datetime module (docs.python.org/3/library/datetime.html)
  • Python documentation — zoneinfo module (docs.python.org/3/library/zoneinfo.html)
  • Paul Ganssle — "Stop Using pytz" (blog.ganssle.io)
  • NumPy documentation — busday_count (numpy.org)

VR

著者について

Vikram Rao

Senior Software Engineer

Vikram Rao has been writing timezone-resilient software for fourteen years, building scheduling infrastructure for distributed teams. He has spoken at multiple developer conferences on the surprisingly difficult topic of handling dates and

全プロフィールを読む →
ブログに戻る

関連記事