
Working with Dates and Times in Python Using datetime and zoneinfo
Dates and times look simple until a user in another country books a meeting, a scheduled job runs twice on the night the clocks change, or a timestamp from your API comes back an hour off. Most of those bugs come from one root cause: mixing up "a moment in time" with "what a clock on the wall says somewhere".
Python's standard library has everything you need to get this right. The datetime module gives you dates, times, and durations, and zoneinfo (added in Python 3.9) gives you real time zones backed by the IANA time zone database, the same data your operating system uses. You don't need pytz anymore, and for most apps you don't need a third-party date library either.
This post covers the core types, the naive vs aware distinction, converting between time zones, arithmetic across daylight saving transitions, parsing and formatting, and Unix timestamps. Examples run on Python 3.13.
The Core Types
The datetime module has four main classes:
| Type | Represents | Example |
|---|---|---|
date | A calendar date | date(2026, 9, 18) |
time | A time of day | time(14, 30) |
datetime | A date plus a time | datetime(2026, 9, 18, 14, 30) |
timedelta | A duration | timedelta(days=2, hours=5) |
There's also timezone, a fixed-offset time zone, and tzinfo, the abstract base class that ZoneInfo implements.
from datetime import date, datetime, time
d = date(2026, 9, 18)
print(d, d.weekday(), d.isoweekday(), d.strftime("%A"))
print(d.replace(day=1))
print(time(14, 30))
print(datetime(2026, 9, 18, 14, 30))
2026-09-18 4 5 Friday
2026-09-01
14:30:00
2026-09-18 14:30:00
weekday() counts Monday as 0; isoweekday() counts Monday as 1. All of these objects are immutable, so methods like replace() return a new object rather than changing the original.
Naive vs Aware: The Most Important Distinction
A datetime is either naive or aware:
- A naive datetime has no time zone (
tzinfo is None).datetime(2026, 9, 18, 14, 30)means "14:30 on 18 September" with no indication of where. It isn't a specific moment; it's ambiguous by up to 26 hours depending on the location. - An aware datetime carries a
tzinfo, so it pins down an exact instant.
from datetime import UTC, datetime
naive = datetime(2026, 9, 18, 14, 30)
aware = datetime(2026, 9, 18, 14, 30, tzinfo=UTC)
print(repr(naive))
print(repr(aware))
datetime.datetime(2026, 9, 18, 14, 30)
datetime.datetime(2026, 9, 18, 14, 30, tzinfo=datetime.timezone.utc)
datetime.UTC is an alias for datetime.timezone.utc, added in Python 3.11. Use whichever you find more readable.
Python refuses to order naive and aware datetimes against each other, because there's no correct answer:
naive < aware
# TypeError: can't compare offset-naive and offset-aware datetimes
Equality is worse: naive == aware doesn't raise, it just returns False. That's an easy way to write a check that silently never matches.
The Rule: Be Aware, Store UTC
Here's the approach that avoids nearly every time zone bug:
- Make every datetime aware as early as possible, right where it enters your program.
- Store and transmit in UTC. Databases, logs, queues, and APIs should all speak UTC with an explicit offset.
- Convert to a local time zone only for display, at the last moment, using the user's zone.
To get the current time, always pass a time zone:
from datetime import UTC, datetime
now = datetime.now(UTC) # aware, in UTC
Avoid datetime.utcnow() and datetime.utcfromtimestamp(). They return naive datetimes that happen to hold UTC values, which other code will happily misinterpret as local time. Both are deprecated as of Python 3.12 and emit a DeprecationWarning.
Plain datetime.now() with no argument returns naive local time. It's fine for a quick script's printout but shouldn't go into storage.
Time Zones with zoneinfo
ZoneInfo takes an IANA zone name like "America/New_York", "Europe/Berlin", or "Asia/Kolkata", and knows the full history of offsets and daylight saving rules for that place.
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
meeting = datetime(2026, 9, 18, 9, 0, tzinfo=ny)
print(meeting, meeting.tzname())
print(meeting.astimezone(UTC))
print(meeting.astimezone(ZoneInfo("Asia/Tokyo")))
print(meeting.astimezone(ZoneInfo("Asia/Kolkata")))
2026-09-18 09:00:00-04:00 EDT
2026-09-18 13:00:00+00:00
2026-09-18 22:00:00+09:00
2026-09-18 18:30:00+05:30
The same zone produces a different offset in winter, because the rules are applied per date:
winter = datetime(2026, 1, 15, 9, 0, tzinfo=ny)
print(winter, winter.tzname()) # 2026-01-15 09:00:00-05:00 EST
This is why you should store zone names, not offsets. -04:00 is only correct for New York for part of the year. If a user's profile needs a time zone, save "America/New_York".
Where the Time Zone Data Comes From
zoneinfo reads the system's time zone database on Linux and macOS. Windows doesn't ship one, and neither do some slim Docker images, so install the first-party tzdata package as a fallback:
python -m pip install tzdata
An unknown key raises zoneinfo.ZoneInfoNotFoundError. zoneinfo.available_timezones() returns the set of every valid key if you need to build a dropdown or validate user input.
astimezone vs replace
These two look similar and do very different things:
utc_noon = datetime(2026, 9, 18, 12, 0, tzinfo=UTC)
print(utc_noon.astimezone(ny)) # same instant, New York wall clock
print(utc_noon.replace(tzinfo=ny)) # same wall clock, different instant!
2026-09-18 08:00:00-04:00
2026-09-18 12:00:00-04:00
astimezone(tz)converts: it keeps the moment and changes the clock reading.replace(tzinfo=tz)relabels: it keeps the clock reading and changes the moment.
Use replace(tzinfo=...) only to attach a zone to a naive value that you know represents local time in that zone, for example a time a user typed into a form:
from datetime import datetime
from zoneinfo import ZoneInfo
entered = datetime(2026, 9, 18, 9, 0) # naive, from a form
local = entered.replace(tzinfo=ZoneInfo("Europe/London"))
print(local) # 2026-09-18 09:00:00+01:00
datetime.combine(date, time, tzinfo=...) does the same job when you have separate date and time inputs.
If you call astimezone() with no argument, Python converts to the machine's local time zone. On a naive datetime, it assumes the value is in local time first. Servers usually run in UTC, so that's another source of "works on my laptop" bugs.
Arithmetic with timedelta
Subtracting two dates or datetimes gives a timedelta, and you can add a timedelta to move forward or back:
from datetime import date, timedelta
td = timedelta(days=2, hours=5, minutes=30)
print(td, td.total_seconds())
print(date(2026, 12, 25) - date(2026, 9, 18))
print((date(2026, 12, 25) - date(2026, 9, 18)).days)
print(date(2026, 1, 31) + timedelta(days=30))
2 days, 5:30:00 192600.0
98 days, 0:00:00
98
2026-03-02
Internally a timedelta stores only days, seconds, and microseconds. Use total_seconds() to get the full duration; the .seconds attribute is only the leftover seconds after whole days are removed (19800 here), which is a common mistake.
Negative durations print a little oddly, because the days part carries the sign: timedelta(hours=-1) displays as -1 day, 23:00:00.
timedelta has no months or years argument, because "one month" isn't a fixed length. Adding 30 days to 31 January gives 2 March, not "the end of February". For calendar-aware month arithmetic, use the third-party python-dateutil package's relativedelta, or write explicit logic for your business rule.
Daylight Saving Time Edge Cases
DST transitions create two strange situations each year. In the US in 2026, clocks spring forward at 2:00 on 8 March and fall back at 2:00 on 1 November.
Wall-Clock Arithmetic vs Elapsed Time
Adding a timedelta to an aware datetime does wall-clock arithmetic in that zone. "One day later" means "same clock time tomorrow", even when tomorrow has 25 hours:
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
before = datetime(2026, 10, 31, 12, 0, tzinfo=ny)
after = before + timedelta(days=1)
print(after) # wall clock
print(after.astimezone(UTC) - before.astimezone(UTC)) # real elapsed time
2026-11-01 12:00:00-05:00
1 day, 1:00:00
Noon to noon across the fall-back night is 25 real hours. That's often what you want for things like "remind me at the same time tomorrow". If you need exactly 24 elapsed hours, for example for a session expiry or a rate limit, do the arithmetic in UTC and convert back:
exact = (before.astimezone(UTC) + timedelta(days=1)).astimezone(ny)
print(exact) # 2026-11-01 11:00:00-05:00
A related subtlety: subtracting two aware datetimes that share the same ZoneInfo object also compares wall clocks, so after - before above reports 1 day, 0:00:00. Convert to UTC first when you need the true difference, as shown.
Times That Happen Twice: fold
On the fall-back night, 1:30 AM happens twice: once in EDT and again an hour later in EST. The fold attribute picks which one you mean. fold=0 (the default) is the first occurrence, and fold=1 is the second:
first = datetime(2026, 11, 1, 1, 30, tzinfo=ny)
second = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=1)
print(first, first.astimezone(UTC))
print(second, second.astimezone(UTC))
2026-11-01 01:30:00-04:00 2026-11-01 05:30:00+00:00
2026-11-01 01:30:00-05:00 2026-11-01 06:30:00+00:00
Times That Never Happen
On the spring-forward night, 2:30 AM doesn't exist in New York. Python doesn't raise; it gives you a datetime that normalizes to a real time when you round-trip it through UTC:
gap = datetime(2026, 3, 8, 2, 30, tzinfo=ny)
print(gap.astimezone(UTC).astimezone(ny)) # 2026-03-08 03:30:00-04:00
If your app schedules recurring events at local times between 1:00 and 3:00 AM, decide explicitly how you want to handle these cases. A daily job scheduled at 2:30 AM local time will be skipped or run twice on transition nights unless you schedule it in UTC.
Parsing and Formatting
ISO 8601: The Format to Prefer
For anything machine-readable, use ISO 8601. isoformat() writes it and fromisoformat() reads it:
from datetime import date, datetime
from zoneinfo import ZoneInfo
dt = datetime(2026, 9, 18, 14, 5, 9, tzinfo=ZoneInfo("America/New_York"))
print(dt.isoformat())
print(dt.isoformat(timespec="minutes"))
print(datetime.fromisoformat("2026-09-18T14:05:00Z"))
print(datetime.fromisoformat("2026-09-18T14:05:00+05:30"))
print(date.fromisoformat("2026-09-18"))
2026-09-18T14:05:09-04:00
2026-09-18T14:05-04:00
2026-09-18 14:05:00+00:00
2026-09-18 14:05:00+05:30
2026-09-18
Since Python 3.11, fromisoformat() accepts most ISO 8601 forms, including the trailing Z that JavaScript's toISOString() produces. Before 3.11 it only accepted the output of isoformat() itself, which is why older code is full of .replace("Z", "+00:00").
Note that a string without an offset parses into a naive datetime. Validate or attach a zone when the input comes from outside your program.
strftime and strptime for Custom Formats
For human-facing output, or for parsing formats that aren't ISO, use format codes. strftime formats ("string format time"), strptime parses ("string parse time"):
print(dt.strftime("%Y-%m-%d %H:%M:%S %Z (%z)"))
print(dt.strftime("%a %d %b %Y, %I:%M %p"))
print(f"{dt:%B %d, %Y}") # format codes work inside f-strings too
parsed = datetime.strptime("18/09/2026 14:05", "%d/%m/%Y %H:%M")
print(repr(parsed))
print(datetime.strptime("2026-09-18 14:05 +0200", "%Y-%m-%d %H:%M %z"))
2026-09-18 14:05:09 EDT (-0400)
Fri 18 Sep 2026, 02:05 PM
September 18, 2026
datetime.datetime(2026, 9, 18, 14, 5)
2026-09-18 14:05:00+02:00
The codes you'll use most:
| Code | Meaning | Example |
|---|---|---|
%Y / %m / %d | Year, month, day (zero-padded) | 2026, 09, 18 |
%H / %I / %M / %S | Hour (24h), hour (12h), minute, second | 14, 02, 05, 09 |
%p | AM/PM | PM |
%a / %A | Weekday, short/full | Fri, Friday |
%b / %B | Month name, short/full | Sep, September |
%z / %Z | UTC offset / zone abbreviation | -0400, EDT |
%j | Day of the year | 261 |
Two things to know: names like %A and %B follow the process locale, and %Z abbreviations like EST or IST are ambiguous, so strptime can't reliably turn them back into a zone. Parse offsets with %z and keep zone names separately.
strptime raises ValueError when the input doesn't match the format, which makes it a decent validator for user input:
datetime.strptime("2026-13-01", "%Y-%m-%d")
# ValueError: time data '2026-13-01' does not match format '%Y-%m-%d'
Python 3.14 adds date.strptime() and time.strptime() class methods, so you no longer have to parse a full datetime and call .date() on it. On 3.13, use datetime.strptime(...).date().
Unix Timestamps
A Unix timestamp is the number of seconds since 1970-01-01 00:00 UTC. It's unambiguous, which is why it shows up in JWTs, Stripe events, and file metadata.
from datetime import UTC, datetime
print(datetime.fromtimestamp(1789689600, tz=UTC))
print(datetime(2026, 9, 18, tzinfo=UTC).timestamp())
2026-09-18 00:00:00+00:00
1789689600.0
Always pass tz= to fromtimestamp(). Without it, you get a naive datetime in the machine's local time zone. And be careful with .timestamp() on a naive datetime: Python assumes it's local time, so the result depends on the server's time zone setting.
JavaScript uses milliseconds, not seconds. Divide by 1000 when reading a Date.now() value.
Putting It Together
Here's a small helper module that follows the rules above: accept input in a user's zone, store in UTC, display in any zone.
# app/timeutils.py
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
def utc_now() -> datetime:
return datetime.now(UTC)
def from_user_input(value: str, tz_name: str) -> datetime:
"""Parse 'YYYY-MM-DD HH:MM' typed in the user's zone and return UTC."""
local = datetime.strptime(value, "%Y-%m-%d %H:%M").replace(tzinfo=ZoneInfo(tz_name))
return local.astimezone(UTC)
def for_display(moment: datetime, tz_name: str) -> str:
if moment.tzinfo is None:
raise ValueError("refusing to display a naive datetime")
return moment.astimezone(ZoneInfo(tz_name)).strftime("%a %d %b %Y, %H:%M %Z")
stored = from_user_input("2026-09-18 09:00", "America/New_York")
print(stored.isoformat())
print(for_display(stored, "Europe/Berlin"))
print(for_display(stored, "Asia/Tokyo"))
2026-09-18T13:00:00+00:00
Fri 18 Sep 2026, 15:00 CEST
Fri 18 Sep 2026, 22:00 JST
When these values cross a boundary as JSON, send isoformat() strings; the JSON post shows how to plug that into a custom encoder. If you're storing them in MongoDB, the post on handling dates and time zones in MongoDB covers the database side.
Conclusion
Almost every date bug comes from a naive datetime that someone assumed was in a particular zone. Make datetimes aware the moment they enter your code, keep them in UTC while they're stored or moving between systems, and convert with astimezone() and ZoneInfo only when a human needs to read them.
Beyond that, remember the handful of sharp edges: replace(tzinfo=...) relabels instead of converting, timedelta on an aware datetime is wall-clock arithmetic, ambiguous and missing local times exist twice a year, and fromtimestamp() needs tz=. Get those right and the standard library handles the rest.


