Working with dates and time zones: a step-by-step guide
Timezone bugs are among the hardest to track down because they often slip through testing on a developer's local machine. We've all shipped a feature that works perfectly in one region but breaks silently in another, causing subtle data corruption or confusing user-facing timestamps. This guide walks through the practical patterns that prevent these costly mistakes in production systems.
Why timezone handling matters in backend systems
When we build APIs, scheduled jobs, or data pipelines, we're often working across multiple regions and timezones. A user in Tokyo creates an order at 9 PM their time; a warehouse team in London needs to process it the next morning; an analytics dashboard in São Paulo reports on it hours later. If we don't handle timezones consistently, we create a breeding ground for data inconsistency, audit log corruption, and subtle race conditions.
The cost isn't just bugs—it's the time spent debugging. A timestamp off by a few hours might pass initial testing but surface only when users from specific regions report issues. That's why building timezone awareness into our code from day one is non-negotiable.
Always use timezone-aware datetimes
Python's datetime module lets us create naive datetimes (without timezone info) and aware datetimes (with timezone info). A naive datetime doesn't know what timezone it represents—it's just a wall-clock time floating in space. In production, this is dangerous.
Consider this code:
from datetime import datetime
# Naive datetime—no timezone info
created_at = datetime.now()
print(created_at) # 2024-01-15 14:30:00
# Aware datetime—includes timezone
from zoneinfo import ZoneInfo
created_at_aware = datetime.now(ZoneInfo("UTC"))
print(created_at_aware) # 2024-01-15 14:30:00+00:00The difference is subtle but critical. When we store or transmit a naive datetime, no one knows if it's UTC, local time, or something else. The next developer to read it will guess wrong. When we use aware datetimes, the timezone is explicit and machine-readable.
In a real system, always create timezone-aware datetimes at the point of creation. If you're building an API endpoint that accepts a user's local time, parse it with timezone info:
from zoneinfo import ZoneInfo
from datetime import datetime
def create_appointment(local_time_str, user_timezone):
"""Accept user's local time, store as UTC."""
user_tz = ZoneInfo(user_timezone)
local_dt = datetime.fromisoformat(local_time_str)
# Localize the naive datetime to the user's timezone
aware_dt = local_dt.replace(tzinfo=user_tz)
return aware_dtWhen we receive 2024-01-15 14:30:00 from a user in Tokyo, we immediately bind it to Asia/Tokyo timezone, making it unambiguous.
Store UTC, convert on display
This is the golden rule: store everything in UTC, convert to display timezone only when serving to a user or external system. UTC is the universal reference frame. It has no daylight saving time, no regional politics, and every system understands it.
In practice, this means:
- When data enters your system, convert it to UTC immediately
- Store all timestamps in UTC in your database
- When returning data to a client, convert from UTC to the user's timezone
Here's a practical example in a Flask API:
from flask import Flask, request, jsonify
from datetime import datetime
from zoneinfo import ZoneInfo
from pytz import timezone as pytz_timezone
app = Flask(__name__)
@app.route('/appointments', methods=['POST'])
def create_appointment():
"""User submits appointment in their local timezone."""
data = request.json
user_timezone = data.get('timezone', 'UTC')
local_time_str = data.get('appointment_time') # "2024-01-15 14:30:00"
# Parse as naive, then localize to user's timezone
user_tz = ZoneInfo(user_timezone)
naive_dt = datetime.fromisoformat(local_time_str)
aware_local = naive_dt.replace(tzinfo=user_tz)
# Convert to UTC for storage
utc_dt = aware_local.astimezone(ZoneInfo('UTC'))
# Save to database (pseudocode)
# db.appointments.insert({'scheduled_at': utc_dt})
return jsonify({'stored_utc': utc_dt.isoformat()})
@app.route('/appointments/', methods=['GET'])
def get_appointment(id):
"""Return appointment in user's local timezone."""
user_timezone = request.args.get('timezone', 'UTC')
# Retrieve from database (pseudocode)
# appointment = db.appointments.find_one({'id': id})
# utc_dt = appointment['scheduled_at']
utc_dt = datetime.now(ZoneInfo('UTC')) # Simulated UTC from DB
# Convert to user's timezone for display
user_tz = ZoneInfo(user_timezone)
local_dt = utc_dt.astimezone(user_tz)
return jsonify({'appointment_time': local_dt.isoformat()})Notice the pattern: UTC in the database is your source of truth. Everything else is a view of that truth, tailored to the user's context.
zoneinfo is built in
For years, Python developers relied on third-party libraries like pytz or arrow` to handle timezones. Starting with Python 3.9, the zoneinfo module is part of the standard library. It's lighter, more standards-compliant, and removes a dependency.
zoneinfo uses the IANA timezone database, which is updated periodically as regions change their rules. On most systems, these updates are managed by the OS. On others (like containerized environments), you may need to install the tzdata package:
pip install tzdataHere's how to use it:
from zoneinfo import ZoneInfo
from datetime import datetime
# Create a UTC datetime
now_utc = datetime.now(ZoneInfo('UTC'))
# Convert to another timezone
tokyo_tz = ZoneInfo('Asia/Tokyo')
now_tokyo = now_utc.astimezone(tokyo_tz)
print(now_utc) # 2024-01-15 10:30:00+00:00
print(now_tokyo) # 2024-01-15 19:30:00+09:00
# Ambiguous times during DST transitions are handled gracefully
ambiguous_time = datetime(2024, 3, 10, 2, 30)
# During spring-forward, 2:30 AM occurs twice
eastern = ZoneInfo('America/New_York')
aware = ambiguous_time.replace(tzinfo=eastern, fold=0) # fold=0 for first occurrence
print(aware) # 2024-03-10 02:30:00-05:00 (EST, before transition)
aware_after = ambiguous_time.replace(tzinfo=eastern, fold=1) # fold=1 for second
print(aware_after) # 2024-03-10 02:30:00-04:00 (EDT, after transition)The fold parameter handles daylight saving time transitions elegantly. Prefer zoneinfo over pytz in new code.
Never compare naive and aware datetimes
Python will raise a TypeError if you try to compare a naive datetime to an aware one. This is actually a feature—it prevents silent bugs. But it means we need to be disciplined about keeping our datetimes consistent.
from datetime import datetime
from zoneinfo import ZoneInfo
naive = datetime.now()
aware = datetime.now(ZoneInfo('UTC'))
# This raises TypeError: can't compare offset-naive and offset-aware datetimes
# if naive < aware:
# print("This won't run")The fix is simple: make everything aware. In a real codebase, you might have a helper:
from datetime import datetime
from zoneinfo import ZoneInfo
def to_aware(dt, tz='UTC'):
"""Convert naive datetime to aware, or return aware datetime as-is."""
if dt.tzinfo is None:
return dt.replace(tzinfo=ZoneInfo(tz))
return dt
# Now comparison works
naive = datetime.now()
aware = datetime.now(ZoneInfo('UTC'))
naive_aware = to_aware(naive)
if naive_aware < aware:
print("Comparison works")This is a simple utility, but it normalizes inconsistencies. However, the better approach is to never create naive datetimes in the first place. Use datetime.now(ZoneInfo('UTC')) instead of datetime.now().
Common mistakes
Mistake 1: Using datetime.now() instead of datetime.now(ZoneInfo('UTC'))
The former creates a naive datetime in the server's local time. If your server runs in one timezone and your database expects UTC, you'll accumulate offset errors. Always be explicit.
Mistake 2: Storing timezone names in the database instead of UTC
You might be tempted to store "America/New_York" alongside a timestamp, thinking you can recover the original timezone. This breaks when daylight saving rules change (which happens), or when a user moves. Store UTC; store the user's preferred timezone separately if you need it for display.
Mistake 3: Parsing ISO strings without timezone info
When you receive 2024-01-15T14:30:00 from an API, it's ambiguous. Always validate that clients send timezone-aware ISO 8601 strings like 2024-01-15T14:30:00+00:00. Or document that your API expects a separate timezone field.
from datetime import datetime
# Bad: assumes the string is UTC
dt = datetime.fromisoformat("2024-01-15T14:30:00")
# Good: explicit about timezone
dt = datetime.fromisoformat("2024-01-15T14:30:00+00:00")Mistake 4: Comparing timestamps across timezone conversions
Two users in different timezones might see the same event as happening on different days (due to local date changes), but the UTC timestamp is identical. Don't use local dates for comparisons; use UTC timestamps.
Key takeaways
- Always create timezone-aware datetimes. Use
datetime.now(ZoneInfo('UTC')), neverdatetime.now() - Store all timestamps in UTC. This is your source of truth and works across all regions
- Convert to user timezones only for display. Keep business logic in UTC
- Use Python's built-in
zoneinfomodule (Python 3.9+) instead of external libraries - Never mix naive and aware datetimes in comparisons. The TypeError you get is a feature, not a bug
- Validate incoming timestamps are timezone-aware. Ambiguous times are a common source of data corruption
- Handle daylight saving time transitions explicitly with the
foldparameter when needed - Document your API's timezone expectations clearly. Clients need to know whether to send UTC or local time






Comments
Loading comments…