Mid-level 3 min · March 17, 2026

Python datetime — utcnow() DST Bug That Shifts Dates

datetime.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • The datetime module provides date, time, datetime, timedelta, and timezone classes
  • Use datetime.now(timezone.utc) for an aware UTC timestamp — avoid naive datetime.now()
  • strftime formats a datetime to a string; strptime parses a string into a datetime
  • timedelta supports addition/subtraction but does not handle months or years natively
  • Always use aware datetimes in production: mixing naive and aware raises TypeError
  • The biggest mistake: storing naive local times — a DST transition duplicates or skips them silently

Date and time handling is one of those areas where every language has footguns, and Python is no exception. The datetime module's API is comprehensive but has some confusing names (datetime.datetime vs datetime.date), the timezone story requires an extra library until Python 3.9, and the difference between naive and aware datetimes trips up everyone at least once.

This guide covers the practical patterns for working with dates and times in Python, including the timezone-aware patterns you should use in production.

Creating and Formatting Dates

Creating datetime objects is straightforward but the gotcha is in the naming: datetime.datetime vs datetime.date vs datetime.time. When you call datetime.now() you get a naive local datetime — no timezone, ambiguous. Always prefer datetime.now(timezone.utc) for production code unless you have a specific reason to be naive. strftime is your friend for formatting. The directive letters are confusing at first, but the table is in the docs. The most common mistake is using %m for minutes (should be %M) and %H for hours (24-hour) when you meant %I for 12-hour.

ExamplePYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from datetime import date, time, datetime, timedelta

# Current date and time
today     = date.today()
now       = datetime.now()  # local time, naive (no timezone)

print(today)          # 2026-03-17
print(now)            # 2026-03-17 11:30:45.123456

# Create specific dates
birthday  = date(1990, 6, 15)
meeting   = datetime(2026, 3, 20, 14, 30, 0)

# Format with strftime
print(meeting.strftime('%A, %B %d %Y at %H:%M'))
# Thursday, March 20 2026 at 14:30

print(meeting.strftime('%d/%m/%Y'))  # 20/03/2026
print(meeting.isoformat())           # 2026-03-20T14:30:00

# Parse a string with strptime
parsed = datetime.strptime('20/03/2026 14:30', '%d/%m/%Y %H:%M')
print(parsed)  # 2026-03-20 14:30:00
Output
2026-03-17
2026-03-17 11:30:45.123456
Thursday, March 20 2026 at 14:30
20/03/2026
2026-03-20T14:30:00
2026-03-20 14:30:00
Common strftime Directives
%Y (year), %m (month 01-12), %d (day 01-31), %H (hour 00-23), %M (minute 00-59), %S (second), %A (weekday name), %B (month name).
Production Insight
Parsing with strptime is strict: extra spaces or wrong separators raise ValueError.
Use try-except around strptime to avoid crashing your API.
Log the exact input string when parsing fails — format mismatches are easy to miss.
Key Takeaway
Always prefer aware datetimes in production.
strftime for output, strptime for input — but wrap parsing in try/except.
Use isoformat for machine-readable output.
Choosing a datetime creation method
IfNeed current UTC time for logging/events
UseUse datetime.now(timezone.utc)
IfNeed current local time for display only
UseUse datetime.now() — naive, but fine for UI
IfParsing a known format string
UseUse strptime with exact format string
IfParsing ISO 8601 string from API
UseUse datetime.fromisoformat() — faster and handles TZ

timedelta — Date Arithmetic

timedelta is a duration between two datetimes. You can add or subtract days, seconds, microseconds, milliseconds, minutes, hours, weeks. That's it — no months or years. That's intentional because months and years have variable lengths. If you need to add a month, you must use dateutil.relativedelta or manually adjust. Performance-wise, timedelta arithmetic is O(1) — it's just integer arithmetic under the hood. But be careful when subtracting datetimes that have different timezones: the result is a timedelta, but it's computed in UTC, so the magnitude may surprise you.

ExamplePYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from datetime import datetime, timedelta

now = datetime(2026, 3, 17, 12, 0, 0)

# Add and subtract durations
tomorrow   = now + timedelta(days=1)
last_week  = now - timedelta(weeks=1)
in_90_days = now + timedelta(days=90)

print(tomorrow)    # 2026-03-18 12:00:00
print(last_week)   # 2026-03-10 12:00:00
print(in_90_days)  # 2026-06-15 12:00:00

# Difference between two datetimes
deadline = datetime(2026, 4, 1, 0, 0, 0)
delta    = deadline - now
print(f"{delta.days} days, {delta.seconds // 3600} hours until deadline")

# Days since epoch — useful for calculations
from datetime import date
today = date.today()
d = today - date(2000, 1, 1)
print(f"Days since Y2K: {d.days}")
Output
2026-03-18 12:00:00
2026-03-10 12:00:00
2026-06-15 12:00:00
14 days, 12 hours until deadline
Days since Y2K: 9572
Production Insight
timedelta does not support months or years — a common trap when doing billing cycles.
When subtracting aware datetimes, the result is a timedelta in absolute time, not wall-clock time.
For business day calculations, you must write your own loop or use dateutil.
Key Takeaway
timedelta handles days and weeks only.
For months, use dateutil.relativedelta or manual calendar logic.
Prefer adding timedelta to aware UTC datetimes to avoid DST shifts.

Timezones — Naive vs Aware

A naive datetime has no timezone information — it is ambiguous. An aware datetime has a timezone attached. Always use aware datetimes when storing, comparing, or transmitting times. The old way (pytz) is replaced by zoneinfo in Python 3.9+. zoneinfo uses the IANA timezone database (the 'tz' database) which handles DST transitions and historical changes correctly. The biggest mistake: using replace() to change the timezone. replace(tzinfo=...) does not convert the time — it just stamps a new timezone label. Use astimezone() to convert.

ExamplePYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from datetime import datetime, timezone, timedelta

# Aware datetime — UTC
utc_now = datetime.now(timezone.utc)
print(utc_now)            # 2026-03-17 11:30:45.123456+00:00
print(utc_now.isoformat()) # 2026-03-17T11:30:45.123456+00:00

# Convert to a different timezone
ut_plus5 = timezone(timedelta(hours=5))
ist_time  = utc_now.astimezone(ut_plus5)
print(ist_time)  # 2026-03-17 16:30:45.123456+05:00

# Python 3.9+ — use zoneinfo for named timezones
from zoneinfo import ZoneInfo
london = utc_now.astimezone(ZoneInfo('Europe/London'))
print(london)  # handles BST/GMT automatically

# Never compare naive and aware datetimes
naive = datetime(2026, 3, 17, 12, 0)
try:
    print(naive < utc_now)
except TypeError as e:
    print(f"Cannot compare: {e}")
Output
2026-03-17 11:30:45+00:00
Cannot compare: can't compare offset-naive and offset-aware datetimes
Time is a continuum, not a label
  • naive = a point with no reference frame — ambiguous
  • aware = a point anchored to UTC
  • astimezone() moves the point to a different reference frame
  • replace(tzinfo=...) only changes the label, not the underlying instant
Production Insight
Comparing naive and aware datetimes raises TypeError — catch it to avoid crashes.
When storing in a database, always store UTC (aware or naive? store aware).
If your app runs in multiple regions, make all timestamps UTC and only convert to local for display.
Key Takeaway
Aware datetimes always have tzinfo set.
Never compare naive to aware.
astimezone converts the time; replace(tzinfo=) just stamps a label.

Parsing Dates and Handling Errors

Parsing is where most production issues start. strptime is strict — even a trailing space or wrong case for %B ('March' vs 'MARCH') raises ValueError. fromisoformat is more forgiving for ISO 8601 strings. For real-world messy input, consider dateutil.parser which is lenient but slower. Always wrap parsing in try/except. Log the raw input and the format you expected — without that log, debugging takes twice as long.

ExamplePYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from datetime import datetime, date

# Robust parsing with try/except
def parse_date(s, fmt='%Y-%m-%d'):
    try:
        return datetime.strptime(s, fmt)
    except ValueError as e:
        print(f"Failed to parse '{s}' with format '{fmt}': {e}")
        return None

# Using fromisoformat for ISO strings
iso_str = '2026-03-17T14:30:00+00:00'
dt = datetime.fromisoformat(iso_str)
print(dt)  # 2026-03-17 14:30:00+00:00

# Common mistake: missing leading zero
# '2026-3-17' fails with '%Y-%m-%d'
print(parse_date('2026-3-17', '%Y-%m-%d'))  # None

# dateutil.parser for flexible parsing (third-party)
# pip install python-dateutil
from dateutil import parser as dparser
print(dparser.parse('March 17, 2026 2:30pm'))  # 2026-03-17 14:30:00
Output
2026-03-17 14:30:00+00:00
Failed to parse '2026-3-17' with format '%Y-%m-%d': time data '2026-3-17' does not match format '%Y-%m-%d'
None
2026-03-17 14:30:00
Production Insight
strptime is strict — missing leading zeros are the top parsing failure.
If you accept user input, use dateutil.parser to handle variability.
Always log both the input string and the expected format in the try/except.
Key Takeaway
Parse with try/except — always.
ISO 8601: fromisoformat is best.
Messy input: use dateutil (third party).

Epoch Timestamps and Conversion

Timestamps (seconds since Unix epoch) are common in APIs and databases. Python's datetime.timestamp() converts an aware datetime to a float. For naive datetimes, it assumes local time — another footgun. Use datetime.fromtimestamp(ts, tz=timezone.utc) to convert back to an aware UTC datetime. Note that datetime.utcfromtimestamp() is deprecated (since 3.12) because it returns a naive datetime. Use fromtimestamp with tz=timezone.utc instead.

ExamplePYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from datetime import datetime, timezone

# Aware UTC to timestamp
utc_now = datetime.now(timezone.utc)
ts = utc_now.timestamp()
print(ts)  # e.g., 1779172245.123456

# Timestamp back to aware datetime
back = datetime.fromtimestamp(ts, tz=timezone.utc)
print(back)  # 2026-03-17 11:30:45.123456+00:00

# WARNING: deprecated way (returns naive)
# naive_utc = datetime.utcfromtimestamp(ts)  # DON'T

# Common in APIs: Unix timestamp in milliseconds
ms_ts = 1779172245123
back_from_ms = datetime.fromtimestamp(ms_ts / 1000, tz=timezone.utc)
print(back_from_ms)
Output
1779172245.123456
2026-03-17 11:30:45.123456+00:00
2026-03-17 11:30:45.123456+00:00
Production Insight
datetime.utcfromtimestamp is deprecated and returns naive — avoid it.
Timestamps from APIs are often in milliseconds — divide by 1000 first.
Loss of precision: timestamp() returns a float; microseconds beyond 6 decimal places are truncated.
Key Takeaway
Use fromtimestamp with tz=timezone.utc, not utcfromtimestamp.
Millisecond timestamps: divide by 1000.
Always convert to aware UTC for internal operations.
● Production incidentPOST-MORTEMseverity: high

The Naive Datetime That Cost a Company $40k in Late Fees

Symptom
Payments scheduled on March 14, 2026 were recorded as March 13 in the system for customers in timezones that observed DST spring-forward. The offset was exactly one hour off, shifting the date by one day when converted to UTC.
Assumption
The team assumed datetime.utcnow() was sufficient because 'UTC doesn't have DST'. They stored naive UTC datetimes and did all business logic in local time, assuming the conversion was safe.
Root cause
datetime.utcnow() returns a naive datetime. When stored and later converted to a local timezone (e.g., 'US/Eastern') using astimezone(), the conversion assumed the naive datetime was in local time, not UTC. The one-hour DST offset shifted the date by one day for late-night timestamps.
Fix
Switch to datetime.now(timezone.utc) for all timestamps, store aware datetimes, and use UTC for all business logic. Local time display is only done at the presentation layer using zoneinfo.
Key lesson
  • Never use datetime.utcnow() — it returns a naive datetime with no timezone information.
  • Always store and compare aware datetimes. One hour offset can shift a date boundary.
  • Use timezone.utc for UTC, not a naive assumption.
Production debug guideSymptom → Action guide for common datetime failures4 entries
Symptom · 01
TypeError: can't compare offset-naive and offset-aware datetimes
Fix
Check all datetime values flowing into the comparison. Add .astimezone(timezone.utc) to one side to make both aware. Use datetime.now(timezone.utc) consistently.
Symptom · 02
ValueError: day is out of range for month (e.g., Feb 30)
Fix
Validate input before constructing datetime. Use try-except around datetime() or strptime. Consider using dateutil.parser for robust parsing.
Symptom · 03
Unexpected shift when converting between timezones
Fix
Check if the source datetime is naive or aware. Naive datetimes are treated as local time by astimezone(). Make sure to .replace(tzinfo=...) or use .astimezone() on an aware object.
Symptom · 04
Current time is off by hours in logs
Fix
Verify the system timezone and DateTimeKind. Use environment TZ variable or zoneinfo. Ensure application always uses UTC internally.
★ Quick Debug Cheat Sheet: Python datetimeFast commands and fixes for common datetime prod issues
Can't compare naive and aware datetime
Immediate action
Convert the naive one to aware UTC: naive.replace(tzinfo=timezone.utc)
Commands
python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc))"
python -c "from datetime import datetime; print(datetime.utcnow().replace(tzinfo=timezone.utc))"
Fix now
Standardize all datetime creation to datetime.now(timezone.utc)
strptime parsing fails+
Immediate action
Check the format string against the input string character by character
Commands
python -c "from datetime import datetime; print(repr('2026-03-17 14:30:00')); print(datetime.strptime('2026-03-17 14:30:00', '%Y-%m-%d %H:%M:%S'))"
python -c "from datetime import datetime; print(datetime.fromisoformat('2026-03-17T14:30:00'))"
Fix now
Use datetime.fromisoformat() for ISO 8601 strings when possible
Comparison of datetime Parsing Methods
MethodStrengthsWeaknessesWhen to Use
strptime (built-in)Strict, no extra dependenciesFails on unexpected formatKnown, well-formed input
fromisoformat (built-in)Fast, handles ISO 8601 with timezoneOnly ISO 8601API responses, ISO input
dateutil.parserFlexible, parses many formatsSlower, third-party dependencyUser input, messy dates

Key takeaways

1
Use datetime.now(timezone.utc) for the current UTC time
always timezone-aware.
2
strftime formats a datetime as a string. strptime parses a string into a datetime.
3
timedelta represents a duration
use it for date arithmetic. Months require special handling.
4
Naive datetimes have no timezone; aware datetimes do. Never mix them in comparisons.
5
Python 3.9+ zoneinfo module handles named timezones (Europe/London, US/Eastern) and DST transitions correctly.

Common mistakes to avoid

4 patterns
×

Using datetime.now() or datetime.utcnow() in production

Symptom
Naive datetimes lead to comparison errors or wrong conversions across timezones. Silent data corruption when timestamps cross DST boundaries.
Fix
Use datetime.now(timezone.utc) for UTC. For local time use aware local tz. Store and compare aware datetimes only.
×

Using replace(tzinfo=...) instead of astimezone()

Symptom
The time is unchanged, only the timezone label changes. Results in incorrect display — e.g., 12:00 UTC becomes 12:00 EST instead of 07:00 EST.
Fix
Use astimezone(new_tz) to convert the actual time. reserve replace() for correcting a wrong timezone label on an already correct instant.
×

Adding months via timedelta

Symptom
timedelta has no months argument, so developers try timedelta(days=30) — which fails for months with 31 days or Feb. Business logic becomes incorrect over time.
Fix
Use dateutil.relativedelta (third-party) or write month arithmetic by hand adjusting year/month and clamping day to last valid day.
×

Ignoring parser exceptions

Symptom
strptime raises ValueError on format mismatch, but the exception is unhandled, causing 500 responses in APIs. Input variations like '3/17/2026' vs '03-17-2026' kill the endpoint.
Fix
Always wrap strptime with try/except. Log the input and format. Consider fromisoformat or dateutil.parser for robustness.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a naive and an aware datetime in Python?
Q02SENIOR
How would you convert a datetime from UTC to a local timezone in Python?
Q03JUNIOR
What does strftime('%Y-%m-%dT%H:%M:%S') produce?
Q04SENIOR
How do you handle adding a month to a datetime object?
Q05SENIOR
Why is datetime.utcnow() considered harmful?
Q01 of 05JUNIOR

What is the difference between a naive and an aware datetime in Python?

ANSWER
A naive datetime has no tzinfo attribute — it is ambiguous. An aware datetime has a timezone offset or name attached. Python prevents comparison between naive and aware datetimes to avoid silent bugs. In production, always use aware datetimes.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What is the difference between datetime.now() and datetime.utcnow()?
02
How do I get the timestamp (seconds since epoch) from a datetime?
03
Why does adding timedelta(days=30) not equal one month?
04
Is it safe to use datetime.now() for logging timestamps?
🔥

That's Python Libraries. Mark it forged?

3 min read · try the examples if you haven't

Previous
collections Module in Python
13 / 51 · Python Libraries
Next
regex Module in Python