I've been burned by datetime bugs more times than I'd like to admit. Last month, I shipped a feature that showed users the wrong meeting times because I didn't understand the difference between naive and aware datetimes. My scheduler was comparing UTC times with local times, and everything worked fine... until it didn't.
This post is everything I wish I'd known about Python datetime and time zones before that incident.
datetime Basics (Quick Refresher)
Python's datetime module gives you four main types:
from datetime import date, time, datetime, timedelta
# Date (just the calendar day)
today = date.today()
specific_date = date(2026, 3, 22)
# Time (just the clock time)
noon = time(12, 0, 0)
# Datetime (date + time combined)
now = datetime.now()
specific_moment = datetime(2026, 3, 22, 14, 30, 0)
# Timedelta (a duration)
one_week = timedelta(days=7)What took me a while to internalize: date, time, and datetime are points in time. timedelta is a duration. You can add a duration to a point, but you can't add two points together.
# This makes sense
tomorrow = datetime.now() + timedelta(days=1)
# This also makes sense (difference between two points = duration)
duration = datetime(2026, 4, 1) - datetime(2026, 3, 22) # timedelta(days=10)
# This would be nonsense (and raises TypeError)
# datetime.now() + datetime.now() # ❌timedelta: Duration Math
timedelta supports days, seconds, microseconds, milliseconds, minutes, hours, and weeks. No months or years—those vary in length.
from datetime import datetime, timedelta
now = datetime.now()
# Various durations
one_day = timedelta(days=1)
three_hours = timedelta(hours=3)
half_hour = timedelta(minutes=30)
complex_duration = timedelta(days=2, hours=5, minutes=30)
# Arithmetic
tomorrow = now + one_day
last_week = now - timedelta(weeks=1)
meeting_end = now + timedelta(hours=1, minutes=30)
# Getting components from timedelta
duration = timedelta(days=5, hours=3, minutes=30)
print(duration.days) # 5
print(duration.seconds) # 12600 (3 hours + 30 min in seconds)
print(duration.total_seconds()) # 444600.0 (everything in seconds)One gotcha: timedelta.seconds is not the total seconds. It's the seconds component after extracting days. Use total_seconds() for the full duration.
Formatting with strftime
strftime turns a datetime into a formatted string. "strf" = "string from time".
dt = datetime(2026, 3, 22, 14, 30, 45)
dt.strftime("%Y-%m-%d") # "2026-03-22"
dt.strftime("%B %d, %Y") # "March 22, 2026"
dt.strftime("%I:%M %p") # "02:30 PM"
dt.strftime("%A, %B %d at %H:%M") # "Sunday, March 22 at 14:30"
dt.strftime("%Y-%m-%dT%H:%M:%S") # "2026-03-22T14:30:45" (ISO-ish)The format codes I use most:
| Code | Meaning | Example |
|---|---|---|
%Y | 4-digit year | 2026 |
%m | Month (01-12) | 03 |
%d | Day (01-31) | 22 |
%H | Hour 24h (00-23) | 14 |
%I | Hour 12h (01-12) | 02 |
%M | Minute | 30 |
%S | Second | 45 |
%p | AM/PM | PM |
%A | Full weekday | Sunday |
%B | Full month name | March |
%z | UTC offset | -0400 |
%Z | Timezone name | EDT |
For ISO 8601 format, just use isoformat():
dt = datetime(2026, 3, 22, 14, 30, 45)
dt.isoformat() # "2026-03-22T14:30:45"Parsing with strptime
strptime is the reverse—parsing a string into a datetime. "strp" = "string parse time".
from datetime import datetime
# Parse with explicit format
dt = datetime.strptime("2026-03-22", "%Y-%m-%d")
dt = datetime.strptime("March 22, 2026", "%B %d, %Y")
dt = datetime.strptime("22/03/2026 14:30", "%d/%m/%Y %H:%M")
dt = datetime.strptime("2026-03-22T14:30:45", "%Y-%m-%dT%H:%M:%S")
# ISO format has a dedicated method
dt = datetime.fromisoformat("2026-03-22T14:30:45")The pain with strptime is you need to know the exact format. If you're parsing user input or external data where the format varies, you'll want dateutil (more on that later).
The Naive vs Aware Problem
Here's where I got burned. A naive datetime has no timezone info:
naive = datetime.now()
print(naive.tzinfo) # NoneAn aware datetime knows its timezone:
from datetime import datetime, timezone
aware = datetime.now(timezone.utc)
print(aware.tzinfo) # UTCThe problem? Naive datetimes are ambiguous. When I write datetime(2026, 3, 22, 14, 30), is that 2:30 PM in New York? London? Tokyo? Python doesn't know, and that ambiguity causes bugs.
naive = datetime(2026, 3, 22, 14, 30)
aware = datetime(2026, 3, 22, 14, 30, tzinfo=timezone.utc)
# Can't compare them!
naive < aware # TypeError: can't compare offset-naive and offset-aware datetimesThe rule I now follow: Always use aware datetimes. If I see datetime.now() without a timezone, I treat it as a code smell.
zoneinfo: Modern Timezone Handling (Python 3.9+)
Before Python 3.9, you needed pytz for proper timezone support. Now zoneinfo is in the standard library:
from datetime import datetime
from zoneinfo import ZoneInfo
# Create timezone-aware datetimes
eastern = ZoneInfo("America/New_York")
pacific = ZoneInfo("America/Los_Angeles")
london = ZoneInfo("Europe/London")
# Current time in a specific timezone
now_eastern = datetime.now(eastern)
now_pacific = datetime.now(pacific)
print(now_eastern) # 2026-03-22 14:30:00-04:00
print(now_pacific) # 2026-03-22 11:30:00-07:00Converting between timezones with astimezone():
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# Start with UTC
utc_time = datetime.now(timezone.utc)
# Convert to different zones
eastern = utc_time.astimezone(ZoneInfo("America/New_York"))
tokyo = utc_time.astimezone(ZoneInfo("Asia/Tokyo"))
london = utc_time.astimezone(ZoneInfo("Europe/London"))
# They all represent the same moment!
print(utc_time) # 2026-03-22 18:30:00+00:00
print(eastern) # 2026-03-22 14:30:00-04:00
print(tokyo) # 2026-03-23 03:30:00+09:00To find valid timezone names:
from zoneinfo import available_timezones
# All available zones
zones = available_timezones()
print(len(zones)) # ~600 zones
# Filter for US zones
us_zones = [z for z in zones if z.startswith("US/") or z.startswith("America/")]UTC Best Practices
After my scheduling bug, I adopted a simple rule: store UTC, display local.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def utc_now():
"""Always get current time in UTC."""
return datetime.now(timezone.utc)
def to_user_timezone(utc_dt, user_tz_name):
"""Convert UTC datetime to user's timezone for display."""
user_tz = ZoneInfo(user_tz_name)
return utc_dt.astimezone(user_tz)
# Store this in the database
created_at = utc_now()
# Display to user in New York
display_time = to_user_timezone(created_at, "America/New_York")
print(display_time.strftime("%B %d, %Y at %I:%M %p %Z"))
# "March 22, 2026 at 02:30 PM EDT"Why UTC?
- Unambiguous: UTC doesn't have daylight saving time. No "which 2 AM?" problems.
- Comparable: All timestamps in one zone make sorting and comparison trivial.
- Standard: APIs, databases, and systems generally expect UTC.
# My current pattern
from datetime import datetime, timezone
class Event:
def __init__(self, name: str):
self.name = name
self.created_at = datetime.now(timezone.utc) # Always UTC
def display_time(self, user_tz: str) -> str:
local = self.created_at.astimezone(ZoneInfo(user_tz))
return local.strftime("%B %d, %Y at %I:%M %p %Z")Daylight Saving Time Pitfalls
DST is where datetime gets tricky. Twice a year, in most US timezones:
- Spring forward: 2:00 AM becomes 3:00 AM (one hour disappears)
- Fall back: 2:00 AM happens twice (one hour repeats)
zoneinfo handles this properly:
from datetime import datetime
from zoneinfo import ZoneInfo
eastern = ZoneInfo("America/New_York")
# March 8, 2026: DST starts at 2 AM
# 1:59 AM -> 3:00 AM (2 AM doesn't exist)
before_dst = datetime(2026, 3, 8, 1, 30, tzinfo=eastern)
after_dst = datetime(2026, 3, 8, 3, 30, tzinfo=eastern)
# This is only 1 hour apart, not 2!
print((after_dst - before_dst).seconds / 3600) # 1.0 hourFor ambiguous times during "fall back", use the fold attribute:
from datetime import datetime
from zoneinfo import ZoneInfo
eastern = ZoneInfo("America/New_York")
# November 1, 2026: DST ends, 1:30 AM happens twice
# fold=0: first occurrence (still DST)
# fold=1: second occurrence (standard time)
first_130 = datetime(2026, 11, 1, 1, 30, tzinfo=eastern, fold=0)
second_130 = datetime(2026, 11, 1, 1, 30, tzinfo=eastern, fold=1)
# They're actually 1 hour apart!dateutil for Flexible Parsing
When you don't know the exact format of incoming date strings, python-dateutil is a lifesaver:
pip install python-dateutilfrom dateutil import parser
# It figures out the format automatically
parser.parse("2026-03-22") # datetime(2026, 3, 22, 0, 0)
parser.parse("March 22, 2026") # datetime(2026, 3, 22, 0, 0)
parser.parse("22/03/2026") # datetime(2026, 3, 22, 0, 0)
parser.parse("Mar 22 2026 2:30PM") # datetime(2026, 3, 22, 14, 30)
parser.parse("2026-03-22T14:30:00Z") # datetime with UTC
parser.parse("Sun, 22 Mar 2026 14:30:00 GMT") # datetime with UTCThe parser is smart but not magic. For ambiguous formats like "01/02/2026", you can hint:
# Is this Jan 2 or Feb 1?
parser.parse("01/02/2026", dayfirst=True) # Feb 1 (European)
parser.parse("01/02/2026", dayfirst=False) # Jan 2 (American)relativedelta for Complex Offsets
timedelta can't do months or years because they vary in length. dateutil.relativedelta can:
from datetime import datetime
from dateutil.relativedelta import relativedelta
now = datetime.now()
# Add months and years
next_month = now + relativedelta(months=1)
next_year = now + relativedelta(years=1)
# Complex offsets
future = now + relativedelta(years=1, months=2, days=15)
# Last day of month
end_of_month = now + relativedelta(day=31)
# Next weekday
from dateutil.relativedelta import MO, FR
next_monday = now + relativedelta(weekday=MO)
next_friday = now + relativedelta(weekday=FR)My Checklist for Datetime Code
After all my bugs and learnings, here's what I check:
- Is every datetime aware? If I see
datetime.now()without a timezone, add one. - Am I storing UTC? Convert to local only for display.
- Am I comparing apples to apples? Both datetimes should be in the same timezone (or both UTC).
- Have I considered DST? Especially for scheduling across timezone boundaries.
- Am I parsing safely? Use
dateutil.parserfor untrusted input, or validate format first.
# My utility module
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def utc_now() -> datetime:
"""Current time in UTC (always aware)."""
return datetime.now(timezone.utc)
def ensure_aware(dt: datetime, assume_tz: str = "UTC") -> datetime:
"""Make sure a datetime is timezone-aware."""
if dt.tzinfo is None:
tz = ZoneInfo(assume_tz) if assume_tz != "UTC" else timezone.utc
return dt.replace(tzinfo=tz)
return dt
def to_local(dt: datetime, tz_name: str = "America/New_York") -> datetime:
"""Convert to local timezone for display."""
return dt.astimezone(ZoneInfo(tz_name))
def format_for_display(dt: datetime, tz_name: str = "America/New_York") -> str:
"""Format datetime nicely for users."""
local = to_local(ensure_aware(dt), tz_name)
return local.strftime("%B %d, %Y at %I:%M %p %Z")Common Mistakes I've Made
Mistake 1: Using datetime.now() without timezone
# Wrong
created_at = datetime.now()
# Right
created_at = datetime.now(timezone.utc)Mistake 2: Comparing naive and aware datetimes
# This will raise TypeError
user_time = datetime.now() # naive
deadline = datetime.now(timezone.utc) # aware
if user_time < deadline: # 💥 TypeError
passMistake 3: Assuming local timezone is consistent
# Wrong - what timezone is this?
dt = datetime.now()
# Right - explicit timezone
dt = datetime.now(ZoneInfo("America/New_York"))Mistake 4: Using deprecated utcnow()
# Deprecated in Python 3.12
dt = datetime.utcnow() # Returns naive datetime 😱
# Use this instead
dt = datetime.now(timezone.utc) # Returns aware datetime ✓Mistake 5: Hardcoding UTC offset instead of zone name
# Wrong - doesn't handle DST
eastern = timezone(timedelta(hours=-5))
# Right - handles DST automatically
eastern = ZoneInfo("America/New_York")Summary
- Use aware datetimes always. Naive datetimes are bugs waiting to happen.
- Store UTC, convert to local only for display.
- Use
zoneinfo(Python 3.9+) for timezone handling—it's in the standard library. - Use
dateutil.parserfor parsing strings when you don't control the format. - Use
relativedeltafor month/year arithmetic sincetimedeltacan't do it. - Watch out for DST—some days have 23 or 25 hours.
The scheduling bug I shipped? It would've been caught if I'd just made sure both datetimes were in UTC before comparing. Now I don't write datetime code without asking: "Is this timezone-aware?"