cheat sheet
datetime
Work with dates, times, and timezones in Python using the stdlib datetime module and zoneinfo. Covers aware vs naive datetimes, ISO-8601 parsing, strftime/strptime, timedelta arithmetic, and DST handling.
datetime — Dates, Times, Timezones
What it is
datetime is Python's standard-library module for representing and manipulating dates, times, and durations. Paired with zoneinfo (added in Python 3.9) it gives you timezone-aware datetimes backed by the system IANA tzdata. Reach for datetime for almost everything date-related; consider pendulum or arrow only when you want a fluent API on top or business-calendar helpers — both wrap the same underlying concepts.
Install
datetime and zoneinfo ship with the standard library; on Windows you also need tzdata (because Windows lacks IANA tz files).
# Standard library — no install needed on Linux/macOS.
python -c "import datetime, zoneinfo; print(zoneinfo.available_timezones() and 'ok')"
# Windows: install the IANA tzdata backport
pip install tzdata
Output:
ok
Syntax
datetime exposes four core classes — date, time, datetime, and timedelta — plus timezone and the newer zoneinfo.ZoneInfo. Construct values, then call methods or operators to combine them.
from datetime import date, time, datetime, timedelta, timezone
from zoneinfo import ZoneInfo
date(2026, 5, 25) # date
time(14, 30, 0) # time
datetime(2026, 5, 25, 14, 30, tzinfo=ZoneInfo("UTC")) # aware datetime
timedelta(days=7, hours=3) # duration
Output: (none — constructors return value objects)
The four core classes
| Class | Represents | Example |
|---|---|---|
date | Calendar day (no time) | date(2026, 5, 25) |
time | Wall-clock time (no date) | time(14, 30, 0) |
datetime | A specific instant on a specific day | datetime(2026, 5, 25, 14, 30) |
timedelta | A duration | timedelta(hours=3) |
timezone | Fixed UTC offset | timezone(timedelta(hours=-5)) |
ZoneInfo | IANA-named zone (DST-aware) | ZoneInfo("America/New_York") |
datetime inherits from date, so any datetime instance is also a date — methods like .year, .month, .weekday() work on both.
Aware vs naive datetimes
A naive datetime has no tzinfo and represents wall-clock time in some unspecified zone; an aware datetime carries a tzinfo and unambiguously identifies an instant. Always work with aware datetimes for any code that touches storage, networking, scheduling, or multiple users — naive datetimes are the single biggest source of timezone bugs.
from datetime import datetime
from zoneinfo import ZoneInfo
naive = datetime(2026, 5, 25, 14, 30) # ambiguous
aware = datetime(2026, 5, 25, 14, 30,
tzinfo=ZoneInfo("America/New_York"))
print(naive.tzinfo)
print(aware.tzinfo)
print(aware.isoformat())
Output:
None
America/New_York
2026-05-25T14:30:00-04:00
Rule of thumb: store as UTC, display in the user's local zone, never store naive datetimes. Reject naive input at API boundaries (Pydantic's AwareDatetime type does this automatically).
datetime.now() and datetime.UTC
datetime.now() returns a naive local-time datetime by default — almost never what you want. Pass a tzinfo to get an aware value. Python 3.11 added datetime.UTC as a friendlier alias for timezone.utc.
from datetime import datetime, UTC, timezone
from zoneinfo import ZoneInfo
datetime.now() # NAIVE — avoid
datetime.now(UTC) # aware UTC (Python 3.11+)
datetime.now(timezone.utc) # aware UTC (any version)
datetime.now(ZoneInfo("America/New_York")) # aware NYC time
datetime.utcnow() # DEPRECATED in 3.12 — returns naive UTC
Output: (none — values are timestamps that vary at runtime)
datetime.utcnow()is deprecated in Python 3.12 and slated for removal. It returns a naive datetime whose value happens to be UTC, which makes downstream timezone handling unsafe. Usedatetime.now(UTC)instead.
zoneinfo.ZoneInfo — IANA timezone names
ZoneInfo (PEP 615, Python 3.9+) reads the IANA timezone database from the operating system and applies DST rules correctly. Always use ZoneInfo("Region/City") for human zones; reserve fixed-offset timezone(timedelta(hours=X)) for protocol-level offsets that have no DST.
from datetime import datetime
from zoneinfo import ZoneInfo, available_timezones
# Print three sample zones
zones = ["UTC", "America/New_York", "Europe/Berlin", "Asia/Tokyo"]
for z in zones:
now = datetime(2026, 7, 1, 12, 0, tzinfo=ZoneInfo(z))
print(f"{z:22} {now.isoformat()} utcoffset={now.utcoffset()}")
print(f"\n{len(available_timezones())} zones available")
Output:
UTC 2026-07-01T12:00:00+00:00 utcoffset=0:00:00
America/New_York 2026-07-01T12:00:00-04:00 utcoffset=-1 day, 20:00:00
Europe/Berlin 2026-07-01T12:00:00+02:00 utcoffset=2:00:00
Asia/Tokyo 2026-07-01T12:00:00+09:00 utcoffset=9:00:00
597 zones available
utcoffset() returns a timedelta — -1 day, 20:00:00 is Python's representation of -4:00:00 (because timedeltas are normalised to non-negative seconds).
Converting between timezones with astimezone
astimezone(tz) changes the timezone while keeping the same instant in time — the wall-clock numbers change but the underlying UTC moment does not. Use it to display a stored UTC value to a user in their local zone, or vice versa.
from datetime import datetime
from zoneinfo import ZoneInfo
utc = datetime(2026, 5, 25, 18, 0, tzinfo=ZoneInfo("UTC"))
ny = utc.astimezone(ZoneInfo("America/New_York"))
tok = utc.astimezone(ZoneInfo("Asia/Tokyo"))
print(utc.isoformat())
print(ny.isoformat())
print(tok.isoformat())
print(utc == ny == tok) # same instant — all three are equal
Output:
2026-05-25T18:00:00+00:00
2026-05-25T14:00:00-04:00
2026-05-26T03:00:00+09:00
True
Compare with replace(tzinfo=...), which changes the zone without converting — useful only for attaching a zone to a known-correct naive datetime.
ISO-8601 parsing and formatting
ISO-8601 (2026-05-25T14:30:00+00:00) is the lingua franca of date interchange. Use datetime.isoformat() to produce it and datetime.fromisoformat() to parse it. Python 3.11 widened fromisoformat to accept the full grammar, including Z suffix.
from datetime import datetime
from zoneinfo import ZoneInfo
dt = datetime(2026, 5, 25, 14, 30, tzinfo=ZoneInfo("America/New_York"))
print(dt.isoformat()) # default — colon separator
print(dt.isoformat(sep=" ")) # space separator
print(dt.isoformat(timespec="minutes")) # trim seconds
print(dt.date().isoformat()) # date only
# Parsing
print(datetime.fromisoformat("2026-05-25T14:30:00-04:00"))
print(datetime.fromisoformat("2026-05-25T14:30:00Z")) # 3.11+
print(datetime.fromisoformat("2026-05-25")) # date only OK
Output:
2026-05-25T14:30:00-04:00
2026-05-25 14:30:00-04:00
2026-05-25T14:30-04:00
2026-05-25
2026-05-25 14:30:00-04:00
2026-05-25 14:30:00+00:00
2026-05-25 00:00:00
Pre-3.11
fromisoformatdoes NOT accept theZsuffix. For older Python, replace it:datetime.fromisoformat(s.replace("Z", "+00:00")). For non-ISO formats (RFC 822, RFC 2822, custom layouts), usestrptime.
strftime / strptime — custom formats
strftime formats a datetime to a string; strptime parses a string back to a datetime. Both use the same C-style format codes. Use ISO-8601 + fromisoformat whenever you control the format — strptime is for legacy/external strings.
from datetime import datetime
dt = datetime(2026, 5, 25, 14, 30, 5)
print(dt.strftime("%Y-%m-%d %H:%M:%S")) # 2026-05-25 14:30:05
print(dt.strftime("%a, %d %b %Y")) # Mon, 25 May 2026
print(dt.strftime("%I:%M %p")) # 02:30 PM
print(dt.strftime("%j")) # day-of-year
print(dt.strftime("%V")) # ISO week number
# Parsing
print(datetime.strptime("25/05/2026 14:30", "%d/%m/%Y %H:%M"))
print(datetime.strptime("Mon, 25 May 2026 14:30:05 GMT",
"%a, %d %b %Y %H:%M:%S %Z"))
Output:
2026-05-25 14:30:05
Mon, 25 May 2026
02:30 PM
145
22
2026-05-25 14:30:00
2026-05-25 14:30:05
Common strftime codes
| Code | Meaning | Example |
|---|---|---|
%Y | 4-digit year | 2026 |
%y | 2-digit year | 26 |
%m | Month (01–12) | 05 |
%d | Day (01–31) | 25 |
%B / %b | Full / abbreviated month name | May / May |
%A / %a | Full / abbreviated weekday | Monday / Mon |
%H / %I | 24-hour / 12-hour | 14 / 02 |
%M / %S | Minute / second | 30 / 05 |
%f | Microseconds (6-digit) | 123456 |
%p | AM/PM | PM |
%z | UTC offset (+0000) | -0400 |
%Z | Timezone name | EDT |
%j | Day of year (001–366) | 145 |
%U / %W | Week of year (Sun- / Mon-based) | 21 |
%V | ISO 8601 week | 22 |
%% | Literal % | % |
timedelta — duration arithmetic
timedelta represents a duration (a span between two instants). Add or subtract it from a date/datetime to move forward/backward in time. Subtract two datetimes to get a timedelta.
from datetime import datetime, timedelta, UTC
t0 = datetime(2026, 5, 25, 12, 0, tzinfo=UTC)
t1 = t0 + timedelta(days=7, hours=3)
delta = t1 - t0
print(t1)
print(delta)
print(delta.total_seconds())
print(timedelta(milliseconds=1500))
print(timedelta(weeks=2))
Output:
2026-06-01 15:00:00+00:00
7 days, 3:00:00
615600.0
0:00:01.500000
14 days, 0:00:00
timedeltaacceptsdays,hours,minutes,seconds,milliseconds,microseconds, andweeks, but not months or years — they have no fixed length. Usedateutil.relativedeltafor calendar-aware arithmetic ("one month from today") or compute month/year deltas manually.
DST traps and how to avoid them
Daylight saving time means a local zone is not a constant offset from UTC: clocks spring forward (a wall-clock hour is skipped) and fall back (an hour is repeated). Arithmetic in local time is therefore unsafe — always convert to UTC first, do the math, then convert back.
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
# US spring-forward: 2026-03-08 02:00 -> 03:00 (02:30 does not exist)
spring = datetime(2026, 3, 8, 1, 30, tzinfo=ny)
print(spring + timedelta(hours=1)) # wall-clock math — skips the gap
# Add one hour by going through UTC
correct = (spring.astimezone(ZoneInfo("UTC")) + timedelta(hours=1)).astimezone(ny)
print(correct)
# Fall-back ambiguity: 2026-11-01 01:30 occurs twice
fall = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=0) # first occurrence
fall_repeat = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=1) # second
print(fall.astimezone(ZoneInfo("UTC")))
print(fall_repeat.astimezone(ZoneInfo("UTC")))
Output:
2026-03-08 03:30:00-04:00
2026-03-08 03:30:00-04:00
2026-11-01 05:30:00+00:00
2026-11-01 06:30:00+00:00
The fold attribute (PEP 495, Python 3.6+) disambiguates the repeated hour during fall-back: fold=0 is the first occurrence (still in DST), fold=1 is the second (after DST ends).
Unix timestamps
A Unix timestamp is the number of seconds since 1970-01-01T00:00:00Z. datetime.timestamp() produces one from an aware datetime; datetime.fromtimestamp(ts, tz) parses one. Always pass a tz argument — naive fromtimestamp uses local time and creates a naive value.
from datetime import datetime, UTC
from zoneinfo import ZoneInfo
dt = datetime(2026, 5, 25, 14, 30, tzinfo=UTC)
ts = dt.timestamp()
print(ts)
print(datetime.fromtimestamp(ts, tz=UTC))
print(datetime.fromtimestamp(ts, tz=ZoneInfo("America/New_York")))
# Millisecond timestamps (JavaScript convention)
ms = int(ts * 1000)
print(datetime.fromtimestamp(ms / 1000, tz=UTC))
Output:
1779718200.0
2026-05-25 14:30:00+00:00
2026-05-25 10:30:00-04:00
2026-05-25 14:30:00+00:00
Comparison with pendulum and arrow
pendulum and arrow are third-party libraries that wrap datetime with a fluent API, simpler timezone handling, and human-friendly helpers like add(months=1) or "in 3 hours". Since Python 3.9 added zoneinfo and 3.11 widened fromisoformat, the stdlib has closed most of the gap — but the third-party libraries still win on ergonomics.
| Feature | datetime+zoneinfo | pendulum | arrow |
|---|---|---|---|
| Standard library | ✅ | ❌ | ❌ |
| Aware-by-default constructors | ❌ | ✅ | ✅ |
add(months=N, years=N) | ❌ (need dateutil) | ✅ | ✅ |
| Human-readable diffs | ❌ | ✅ "in 2 hours" | ✅ "2 hours ago" |
| Fluent chaining | ❌ | ✅ | ✅ |
| ISO parsing | ✅ (3.11+ full) | ✅ | ✅ |
| Speed | fastest | slowest | medium |
Rule of thumb: datetime + zoneinfo is enough for ~95% of code on modern Python. Use pendulum or arrow when you have a lot of relative-time logic ("1 month from now at 9am Mountain") and want it to read like English.
Common pitfalls
- Storing naive datetimes — silently assumes "local time" and breaks when the server moves zones. Always store aware UTC.
datetime.now()without atz— returns naive local time. Usedatetime.now(UTC)(3.11+) ordatetime.now(timezone.utc).datetime.utcnow()is deprecated (3.12+) — and returned a naive value masquerading as UTC. Switch todatetime.now(UTC).- Comparing aware to naive datetimes raises
TypeError: can't compare offset-naive and offset-aware datetimes. Normalise both sides first. replace(tzinfo=…)vsastimezone(…)—replacechanges the label without converting the time;astimezoneconverts. Usereplaceonly to attach a zone to a naive datetime you know is in that zone.pytzis legacy — its API requirestz.localize(dt)instead ofdt.replace(tzinfo=tz)because of historical Python limits. Modern code should usezoneinfo.timedeltahas no months — calendar months are variable length. Usedateutil.relativedelta(months=1)for "add one month".strptimeis slow — it goes through C locale handling. For high-throughput parsing of a known format, write a regex or usefromisoformat.- DST gaps and folds —
datetime(2026, 3, 8, 2, 30, tzinfo=ny)is in a non-existent hour;folddisambiguates the repeated hour during fall-back. Always do arithmetic in UTC. %Zand%zare not symmetric —strftime("%Z")gives a name (EDT), butstrptime("%Z")only accepts UTC abbreviations reliably. Round-trip via%z(numeric offset) instead.- Windows lacks tzdata —
pip install tzdatato givezoneinfoa database. date.today()is timezone-dependent — it returns the local date, which can differ from UTC. Preferdatetime.now(UTC).date()for server-side code.
Real-world recipes
Normalise a CSV column of mixed-zone timestamps to UTC
A CSV has timestamps in multiple formats and zones (some ISO with offset, some bare, some Z-suffixed). Parse each into an aware datetime, convert to UTC, and write back.
import csv
from datetime import datetime
from zoneinfo import ZoneInfo
DEFAULT_ZONE = ZoneInfo("America/New_York")
def parse_to_utc(value: str) -> datetime:
"""Parse a timestamp string into an aware UTC datetime."""
s = value.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s)
except ValueError:
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=DEFAULT_ZONE)
return dt.astimezone(ZoneInfo("UTC"))
rows = [
{"id": 1, "ts": "2026-05-25T14:30:00-04:00"},
{"id": 2, "ts": "2026-05-25T18:30:00Z"},
{"id": 3, "ts": "2026-05-25 10:30:00"}, # naive — assumed NY
]
for row in rows:
row["ts_utc"] = parse_to_utc(row["ts"]).isoformat()
print(row)
Output:
{'id': 1, 'ts': '2026-05-25T14:30:00-04:00', 'ts_utc': '2026-05-25T18:30:00+00:00'}
{'id': 2, 'ts': '2026-05-25T18:30:00Z', 'ts_utc': '2026-05-25T18:30:00+00:00'}
{'id': 3, 'ts': '2026-05-25 10:30:00', 'ts_utc': '2026-05-25T14:30:00+00:00'}
"First/last day of month" and "N business days from today"
Calendar math: round to the start/end of the current month and skip weekends. The stdlib gets you most of the way; use calendar.monthrange for end-of-month and a simple loop for business days.
import calendar
from datetime import date, timedelta
today = date(2026, 5, 25)
first = today.replace(day=1)
last = today.replace(day=calendar.monthrange(today.year, today.month)[1])
print(f"month: {first} → {last}")
def add_business_days(d: date, n: int) -> date:
while n:
d += timedelta(days=1)
if d.weekday() < 5: # Mon=0, …, Fri=4
n -= 1
return d
print("5 business days:", add_business_days(today, 5))
print("10 business days:", add_business_days(today, 10))
Output:
month: 2026-05-01 → 2026-05-31
5 business days: 2026-06-01
10 business days: 2026-06-08
Schedule a job in 30 minutes, store as UTC, display in local
A typical scheduler: take "now + 30m in the user's zone", store it as UTC in the database, then re-display it in the user's zone at read time. The conversion at the boundary is the whole game.
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
USER_ZONE = ZoneInfo("America/New_York")
def schedule(minutes_from_now: int) -> datetime:
user_now = datetime.now(USER_ZONE)
return (user_now + timedelta(minutes=minutes_from_now)).astimezone(
ZoneInfo("UTC"))
def display(stored_utc: datetime) -> str:
return stored_utc.astimezone(USER_ZONE).strftime("%a %b %d, %I:%M %p %Z")
stored = schedule(30)
print("STORE: ", stored.isoformat())
print("DISPLAY:", display(stored))
Output:
STORE: 2026-05-25T19:00:00+00:00
DISPLAY: Mon May 25, 03:00 PM EDT
Format durations as human-readable strings
timedelta's str() is fine for logs but ugly for users. A small helper turns it into "2h 15m" or "3 days ago".
from datetime import datetime, timedelta, UTC
def format_duration(td: timedelta) -> str:
s = int(td.total_seconds())
if s < 60: return f"{s}s"
if s < 3600: return f"{s//60}m {s%60}s"
if s < 86400:
h, rem = divmod(s, 3600)
return f"{h}h {rem//60}m"
d, rem = divmod(s, 86400)
return f"{d}d {rem//3600}h"
def humanize(when: datetime, *, now: datetime | None = None) -> str:
now = now or datetime.now(UTC)
delta = now - when
if delta.total_seconds() < 0:
return f"in {format_duration(-delta)}"
return f"{format_duration(delta)} ago"
ref = datetime(2026, 5, 25, 12, 0, tzinfo=UTC)
print(humanize(ref - timedelta(seconds=45), now=ref))
print(humanize(ref - timedelta(hours=2, minutes=15), now=ref))
print(humanize(ref + timedelta(days=3), now=ref))
Output:
45s ago
2h 15m ago
in 3d 0h
Iterate through a date range
A common need: produce every day between two dates, or every Monday for the next year. A simple generator beats pd.date_range for one-off scripts.
from datetime import date, timedelta
def daterange(start: date, end: date, step: timedelta = timedelta(days=1)):
current = start
while current < end:
yield current
current += step
start = date(2026, 5, 25)
end = date(2026, 6, 1)
print([d.isoformat() for d in daterange(start, end)])
# Every Monday
mondays = [d for d in daterange(start, date(2026, 7, 1)) if d.weekday() == 0]
print(mondays)
Output:
['2026-05-25', '2026-05-26', '2026-05-27', '2026-05-28', '2026-05-29', '2026-05-30', '2026-05-31']
[datetime.date(2026, 5, 25), datetime.date(2026, 6, 1), datetime.date(2026, 6, 8), datetime.date(2026, 6, 15), datetime.date(2026, 6, 22), datetime.date(2026, 6, 29)]
Sleep until a specific wall-clock time
A scheduler that wakes up at the next 9am in the user's zone, robust to DST and process restarts.
import time
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
def seconds_until(hour: int, minute: int = 0, *, tz: ZoneInfo) -> float:
now = datetime.now(tz)
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
return (target - now).total_seconds()
wait = seconds_until(9, 0, tz=ZoneInfo("America/New_York"))
print(f"sleeping {wait:.0f}s until 9:00 AM NYC")
# time.sleep(wait) # ← actually sleep in real code
Output:
sleeping 52200s until 9:00 AM NYC