The same way SQL Server’s DATETIMEOFFSET and AT TIME ZONE exist because timezones are hard, Python has spent the last fifteen years fighting through several iterations of the same problem. The current state — Python 3.13, May 2026 — is finally good. datetime for the value, zoneinfo for the timezone, both in the standard library, both correct.
The catch: the language still lets you create datetimes that have no timezone attached. Those are called naive, they look like real datetimes, they print like real datetimes, and if you mix them with the aware kind you get wrong answers or crashes. Most production date bugs trace back to one naive datetime sneaking through.
This lesson is how to keep that from happening.
Naive vs aware: the only distinction that matters
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
naive: datetime = datetime(2026, 5, 1, 14, 30)
# 2026-05-01 14:30:00 — but 14:30 *where*?
aware: datetime = datetime(2026, 5, 1, 14, 30, tzinfo=ZoneInfo("Europe/Rome"))
# 2026-05-01 14:30:00+02:00 — unambiguous
A naive datetime has no tzinfo. It’s just numbers. Python doesn’t know if it means UTC, Rome time, Tokyo time, or something local. It’s a Python convention with no semantic.
An aware datetime has a tzinfo. It refers to a specific moment in time that any other timezone can convert to or from.
You can check:
def is_aware(dt: datetime) -> bool:
return dt.tzinfo is not None and dt.tzinfo.utcoffset(dt) is not None
And the language won’t help you mix them. This raises:
naive - aware
# TypeError: can't subtract offset-naive and offset-aware datetimes
Which is the good outcome. The bad outcome is when you add a timedelta to a naive datetime and use the result somewhere that assumed UTC. No error. Wrong answer. Dashboards drift. Cron jobs fire at the wrong hour. You find out two months later when a customer in Lisbon notices.
The golden rule: store UTC, convert at the edges
The same rule from the SQL lesson applies in Python.
- All datetimes in your database: UTC, aware.
- All datetimes inside your application logic: UTC, aware.
- All datetimes coming in from a user or API: convert to UTC at the boundary.
- All datetimes shown to a user: convert from UTC to their local timezone at the boundary.
UTC has no daylight saving. UTC arithmetic is unambiguous. “1 hour later in UTC” is always 60 minutes. The moment you let local time creep into your business logic, you’ve added a bug.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# Now in UTC, aware
utc_now: datetime = datetime.now(tz=timezone.utc)
# Convert to Rome for a display string
rome_now: datetime = utc_now.astimezone(ZoneInfo("Europe/Rome"))
# Convert back if you ever need to (you won't, you stored UTC)
back: datetime = rome_now.astimezone(timezone.utc)
datetime.now(tz=...) always gives you an aware datetime. Use it. Plain datetime.now() (no tz) returns a naive datetime in the local system timezone, which is the single most common source of date bugs in Python. Pretend it doesn’t exist.
zoneinfo: stdlib since 3.9
For a decade the answer was the third-party pytz library. It worked but had a weird API: you had to call pytz.timezone("Europe/Rome").localize(dt) instead of passing the timezone to the constructor. Mixing it up gave silently wrong results.
zoneinfo, added in Python 3.9 (PEP 615), uses your operating system’s IANA timezone database — the same tzdata package that everything else on the system uses. The API is the obvious one:
from zoneinfo import ZoneInfo
rome: ZoneInfo = ZoneInfo("Europe/Rome")
ny: ZoneInfo = ZoneInfo("America/New_York")
tokyo: ZoneInfo = ZoneInfo("Asia/Tokyo")
On Windows, where there’s no system tzdata, you need to install the tzdata package: pip install tzdata. On Linux and macOS it’s already there. In a container, install the system tzdata package or include the Python tzdata wheel.
Use IANA names: Europe/Rome, America/New_York, Asia/Tokyo. Don’t use offsets like UTC+2 as a timezone — they don’t know about daylight saving. The whole point of a named timezone is that the offset varies through the year.
If you still see pytz in a codebase, plan to migrate. It’s not deprecated yet, but zoneinfo is the future. The conversion is mechanical — pytz.timezone("Europe/Rome") becomes ZoneInfo("Europe/Rome"), and you can drop the .localize() calls.
Parsing and formatting
Reading an ISO 8601 string into a datetime:
from datetime import datetime
dt: datetime = datetime.fromisoformat("2026-05-01T14:30:00+02:00")
# datetime(2026, 5, 1, 14, 30, tzinfo=timezone(timedelta(hours=2)))
In Python 3.11 fromisoformat was upgraded to handle the full ISO 8601 spec — fractional seconds, Z suffix for UTC, basic and extended formats. Before 3.11 it was a more restricted subset. On 3.13, just use it; it covers everything reasonable.
dt = datetime.fromisoformat("2026-05-01T14:30:00Z") # UTC
dt = datetime.fromisoformat("2026-05-01T14:30:00.123456+00:00") # microseconds
Writing back out as ISO:
s: str = dt.isoformat() # '2026-05-01T14:30:00+02:00'
For human formats, strftime:
dt.strftime("%Y-%m-%d") # '2026-05-01'
dt.strftime("%d/%m/%Y %H:%M") # '01/05/2026 14:30'
dt.strftime("%A, %d %B %Y") # 'Friday, 01 May 2026'
The format codes follow C’s strftime: %Y four-digit year, %m zero-padded month, %d zero-padded day, %H 24-hour clock, %M minutes, %S seconds. Look up the table once.
For machine interchange — APIs, JSON, databases — always use ISO 8601. dt.isoformat() and datetime.fromisoformat(). No locale-specific formats anywhere near a wire protocol.
timedelta: arithmetic on time
from datetime import datetime, timedelta, timezone
now: datetime = datetime.now(tz=timezone.utc)
one_hour_ago: datetime = now - timedelta(hours=1)
one_week_later: datetime = now + timedelta(days=7)
ninety_seconds: timedelta = timedelta(minutes=1, seconds=30)
# Difference between two datetimes is a timedelta
elapsed: timedelta = now - one_hour_ago
elapsed.total_seconds() # 3600.0
timedelta is the only object you should use for “shift by some amount of time.” It’s straightforward and well-defined.
What timedelta doesn’t have: months and years. Because months aren’t a fixed length. “1 month after January 31” is sometimes February 28, sometimes February 29, depending on the year. There’s no timedelta(months=1).
For month and year arithmetic, use dateutil.relativedelta (the only thing in the dateutil package that’s still essential):
from dateutil.relativedelta import relativedelta
dt: datetime = datetime(2026, 1, 31, tzinfo=timezone.utc)
dt + relativedelta(months=1) # 2026-02-28 (clamped)
dt + relativedelta(years=1) # 2027-01-31
dt + relativedelta(months=1, days=-1) # last day of Feb minus extra logic
It’s third-party (pip install python-dateutil), but it’s a stable dependency that’s been around since Python 2.5. For any “next month” or “1 year ago” calculation, reach for it.
The DST trap
The reason naive datetimes are dangerous, in one example:
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
rome: ZoneInfo = ZoneInfo("Europe/Rome")
# Saturday before DST starts in 2026 (DST begins Sunday March 29 in Rome)
sat: datetime = datetime(2026, 3, 28, 12, 0, tzinfo=rome)
# Naive add: "24 hours later"
sun: datetime = sat + timedelta(hours=24)
print(sun) # 2026-03-29 13:00:00+02:00 — note: 13:00, not 12:00
24 hours after Saturday noon is Sunday 13:00, not Sunday noon, because the clocks jumped forward at 02:00. If you wanted “same time of day, next day,” timedelta(days=1) is the wrong tool. You want relativedelta(days=1), which keeps the wall-clock time:
sun = sat + relativedelta(days=1)
print(sun) # 2026-03-29 12:00:00+02:00 — same wall time, different UTC moment
The two are different moments in real time. Pick whichever your business actually means. “Send a reminder 24 hours later” probably wants timedelta. “Run the meeting at the same local time tomorrow” wants relativedelta.
This is the kind of bug that hides for half the year. Test your scheduling code against a date right after a DST transition.
Real-world example: a meeting across timezones
from datetime import datetime
from zoneinfo import ZoneInfo
# A meeting set in Rome local time
meeting_rome: datetime = datetime(2026, 6, 15, 10, 0, tzinfo=ZoneInfo("Europe/Rome"))
# Convert for participants in NYC and Tokyo
attendees: dict[str, ZoneInfo] = {
"Marco (Rome)": ZoneInfo("Europe/Rome"),
"Alex (NYC)": ZoneInfo("America/New_York"),
"Yuki (Tokyo)": ZoneInfo("Asia/Tokyo"),
}
for name, tz in attendees.items():
local: datetime = meeting_rome.astimezone(tz)
print(f"{name}: {local.strftime('%Y-%m-%d %H:%M %Z')}")
Output:
Marco (Rome): 2026-06-15 10:00 CEST
Alex (NYC): 2026-06-15 04:00 EDT
Yuki (Tokyo): 2026-06-15 17:00 JST
Three correct local times, no off-by-one-hour bug, no manual offset arithmetic, no second-guessing whether daylight saving is in effect today. You define the moment once, in one timezone, and let astimezone produce the rest.
The database boundary
If you’re using SQLAlchemy or Django, configure your TIMESTAMPTZ columns and the ORMs will give you aware datetimes back. With SQLAlchemy:
from sqlalchemy import Column, DateTime
from sqlalchemy.orm import declarative_base
from datetime import datetime, timezone
Base = declarative_base()
class Order(Base):
__tablename__ = "orders"
id: int = Column(Integer, primary_key=True)
created_at: datetime = Column(DateTime(timezone=True),
default=lambda: datetime.now(tz=timezone.utc))
DateTime(timezone=True) maps to PostgreSQL TIMESTAMPTZ and returns aware datetimes. The default callable uses UTC. Combined, every row has an unambiguous timestamp from creation to display.
Comparing and sorting
Aware datetimes compare correctly across timezones, because under the hood they’re points in absolute time:
from datetime import datetime
from zoneinfo import ZoneInfo
rome_noon: datetime = datetime(2026, 5, 1, 12, 0, tzinfo=ZoneInfo("Europe/Rome"))
ny_noon: datetime = datetime(2026, 5, 1, 12, 0, tzinfo=ZoneInfo("America/New_York"))
rome_noon < ny_noon # True — NY noon is 6 hours after Rome noon
This works because comparing aware datetimes converts both sides to UTC. Naive vs aware in the same comparison raises TypeError. Naive vs naive compares as raw numbers, with no timezone awareness — which is occasionally what you want and usually a bug.
Sorting a list of aware datetimes from different timezones works as you’d expect: the order is by absolute moment in time, not by wall clock.
events: list[datetime] = [
datetime(2026, 5, 1, 14, 0, tzinfo=ZoneInfo("Europe/Rome")),
datetime(2026, 5, 1, 9, 0, tzinfo=ZoneInfo("America/New_York")),
datetime(2026, 5, 1, 22, 0, tzinfo=ZoneInfo("Asia/Tokyo")),
]
for e in sorted(events):
print(e.astimezone(ZoneInfo("UTC")))
A few more details worth knowing
The date type without a time. from datetime import date. Useful for birthdays, deadlines, anything where time-of-day is meaningless. date is always naive — there’s no notion of timezone for a calendar date by itself. date.today() returns the local date, which has the same caveat as datetime.now(): it depends on the system timezone.
datetime.utcnow() is deprecated. Removed in Python 3.12. It returned a naive datetime in UTC, which is the worst of both worlds: looks naive, secretly UTC, easy to confuse with local naive time. Replacement: datetime.now(tz=timezone.utc).
time.time() for monotonic intervals — sometimes. When you want elapsed time for a benchmark or a timeout, time.monotonic() is the right call. It’s a float of seconds, doesn’t move backward when the system clock is adjusted, and isn’t affected by DST. datetime is for calendar time; time.monotonic() is for measuring durations.
Unix timestamps. dt.timestamp() gives you seconds since the epoch as a float. datetime.fromtimestamp(ts, tz=timezone.utc) parses one back. Always pass tz — the no-arg form returns naive local time.
What to keep in your head
- Every datetime in your code is aware.
datetime.now(tz=timezone.utc)for “now”. Neverdatetime.now().ZoneInfo("Europe/Rome")for timezones. IANA names only. Nopytz.dt.astimezone(tz)to convert.datetime.fromisoformat()anddt.isoformat()for serialization.timedeltafor hours/days.relativedeltafor months/years and “same wall time tomorrow”.- Any naive datetime in your app is a bug or a boundary that needs an explicit
replace(tzinfo=...).
Next lesson: the small data structures that make day-to-day Python pleasant — Counter, defaultdict, deque, and the @dataclass decorator that replaces most of the rest.
References: datetime — Basic date and time types, zoneinfo — IANA time zone support, PEP 615 — Support for the IANA Time Zone Database in the Standard Library. Retrieved 2026-05-01.