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).

bash
# 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:

text
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.

python
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

ClassRepresentsExample
dateCalendar day (no time)date(2026, 5, 25)
timeWall-clock time (no date)time(14, 30, 0)
datetimeA specific instant on a specific daydatetime(2026, 5, 25, 14, 30)
timedeltaA durationtimedelta(hours=3)
timezoneFixed UTC offsettimezone(timedelta(hours=-5))
ZoneInfoIANA-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.

python
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:

text
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.

python
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. Use datetime.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.

python
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:

text
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.

python
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:

text
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.

python
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:

text
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 fromisoformat does NOT accept the Z suffix. For older Python, replace it: datetime.fromisoformat(s.replace("Z", "+00:00")). For non-ISO formats (RFC 822, RFC 2822, custom layouts), use strptime.

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.

python
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:

text
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

CodeMeaningExample
%Y4-digit year2026
%y2-digit year26
%mMonth (01–12)05
%dDay (01–31)25
%B / %bFull / abbreviated month nameMay / May
%A / %aFull / abbreviated weekdayMonday / Mon
%H / %I24-hour / 12-hour14 / 02
%M / %SMinute / second30 / 05
%fMicroseconds (6-digit)123456
%pAM/PMPM
%zUTC offset (+0000)-0400
%ZTimezone nameEDT
%jDay of year (001–366)145
%U / %WWeek of year (Sun- / Mon-based)21
%VISO 8601 week22
%%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.

python
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:

text
2026-06-01 15:00:00+00:00
7 days, 3:00:00
615600.0
0:00:01.500000
14 days, 0:00:00

timedelta accepts days, hours, minutes, seconds, milliseconds, microseconds, and weeks, but not months or years — they have no fixed length. Use dateutil.relativedelta for 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.

python
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:

text
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.

python
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:

text
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.

Featuredatetime+zoneinfopendulumarrow
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)
Speedfastestslowestmedium

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

  1. Storing naive datetimes — silently assumes "local time" and breaks when the server moves zones. Always store aware UTC.
  2. datetime.now() without a tz — returns naive local time. Use datetime.now(UTC) (3.11+) or datetime.now(timezone.utc).
  3. datetime.utcnow() is deprecated (3.12+) — and returned a naive value masquerading as UTC. Switch to datetime.now(UTC).
  4. Comparing aware to naive datetimes raises TypeError: can't compare offset-naive and offset-aware datetimes. Normalise both sides first.
  5. replace(tzinfo=…) vs astimezone(…)replace changes the label without converting the time; astimezone converts. Use replace only to attach a zone to a naive datetime you know is in that zone.
  6. pytz is legacy — its API requires tz.localize(dt) instead of dt.replace(tzinfo=tz) because of historical Python limits. Modern code should use zoneinfo.
  7. timedelta has no months — calendar months are variable length. Use dateutil.relativedelta(months=1) for "add one month".
  8. strptime is slow — it goes through C locale handling. For high-throughput parsing of a known format, write a regex or use fromisoformat.
  9. DST gaps and foldsdatetime(2026, 3, 8, 2, 30, tzinfo=ny) is in a non-existent hour; fold disambiguates the repeated hour during fall-back. Always do arithmetic in UTC.
  10. %Z and %z are not symmetricstrftime("%Z") gives a name (EDT), but strptime("%Z") only accepts UTC abbreviations reliably. Round-trip via %z (numeric offset) instead.
  11. Windows lacks tzdatapip install tzdata to give zoneinfo a database.
  12. date.today() is timezone-dependent — it returns the local date, which can differ from UTC. Prefer datetime.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.

python
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:

text
{'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.

python
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:

text
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.

python
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:

text
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".

python
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:

text
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.

python
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:

text
['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.

python
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:

text
sleeping 52200s until 9:00 AM NYC