Type something to search...
Working with Dates and Times in Python Using datetime and zoneinfo

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:

TypeRepresentsExample
dateA calendar datedate(2026, 9, 18)
timeA time of daytime(14, 30)
datetimeA date plus a timedatetime(2026, 9, 18, 14, 30)
timedeltaA durationtimedelta(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:

  1. Make every datetime aware as early as possible, right where it enters your program.
  2. Store and transmit in UTC. Databases, logs, queues, and APIs should all speak UTC with an explicit offset.
  3. 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:

CodeMeaningExample
%Y / %m / %dYear, month, day (zero-padded)2026, 09, 18
%H / %I / %M / %SHour (24h), hour (12h), minute, second14, 02, 05, 09
%pAM/PMPM
%a / %AWeekday, short/fullFri, Friday
%b / %BMonth name, short/fullSep, September
%z / %ZUTC offset / zone abbreviation-0400, EDT
%jDay of the year261

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.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading