Filter concepts by levelShowing all levels.

Python · Section 47

Date and Time

Level
intermediate
Read
95 min
Concepts
5

The precise semantics behind datetime and zoneinfo that make time-related bugs so common in production: the four core types, real timezone conversion, naive vs. aware datetimes, the two standard interchange formats, and daylight saving time transition edge cases.

This section

What is true here

  1. date, time, datetime, and timedelta each hold something different — mixing date and datetime arithmetic raises TypeError.
  2. A named zone via zoneinfo.ZoneInfo tracks real, changing DST rules; a fixed UTC offset does not and silently drifts wrong for part of the year.
  3. A naive datetime (no tzinfo) does not represent one specific instant — comparing it to an aware datetime raises TypeError rather than a wrong answer.
  4. ISO 8601 text and Unix timestamp numbers are the two standard ways to serialize a datetime across a process boundary.
  5. A DST fall-back repeats a wall-clock hour (disambiguated with fold); a spring-forward skips one entirely, and Python will still construct that impossible reading without complaint.

What you will be able to do

  • Choose the correct one of date, time, datetime, or timedelta for a given value, and combine them with datetime.combine instead of illegal cross-type arithmetic
  • Attach a real IANA zone with zoneinfo.ZoneInfo instead of a fixed UTC offset, and convert between zones with .astimezone() without changing the underlying instant
  • Always construct aware datetimes (datetime.now(timezone.utc)), and explain why a naive/aware comparison raises TypeError instead of silently answering wrong
  • Serialize a datetime as ISO 8601 text or a Unix timestamp, and always pass tz=timezone.utc when parsing a stored timestamp back
  • Use the fold attribute to disambiguate a repeated fall-back hour, and recognize an imaginary spring-forward reading before trusting it

The core types and time zones

What date, time, datetime, and timedelta each hold, and how to attach and convert real time zones correctly.

datetime, date, time, and timedelta

corebeginner

date holds a calendar day, time holds a clock reading with no day, datetime combines both into one point in time, and timedelta holds a duration you can add to or subtract from any of the other three.

Think of it as

Think of date as a page torn from a calendar, time as a reading off a clock with no date printed on it, and datetime as the two taped together into one specific moment. timedelta is not a moment at all — it is a length of time, like "3 days" or "90 minutes", that shifts a date or datetime forward or backward when added.

python
from datetime import date, time, datetime, timedelta

date(2026, 8, 21)                      # calendar day only
time(9, 30)                            # clock reading only
datetime(2026, 8, 21, 9, 30)           # both
datetime.combine(date(2026, 8, 21), time(9, 30))
date(2026, 8, 21) + timedelta(days=10) # -> date(2026, 8, 31)

What we're doing: Compute a countdown in whole days with date, then build a specific meeting instant by combining that date with a time.

countdown.pypython
from datetime import date, time, datetime, timedelta

launch_date = date(2026, 9, 1)
countdown = launch_date - date(2026, 8, 21)
print(countdown)
print(countdown.days)

standup_dt = datetime.combine(launch_date, time(9, 30))
print(standup_dt)
print(standup_dt + timedelta(hours=1, minutes=30))
4
Subtracting two date objects gives a timedelta measured in whole days — there is no clock time to lose precision on.
5
.days pulls the integer day count out of the timedelta.
7
datetime.combine(launch_date, time(9, 30)) builds one specific instant out of a separate date and time value.
Output
11 days, 0:00:00
11
2026-09-01 09:30:00
2026-09-01 11:00:00

Why this works: date subtraction is exact because a date has no time-of-day component to round — the result is always a whole number of days. datetime.combine is the standard way to build a specific instant when a date and a time of day come from separate sources, such as a date picker and a time picker in a form.

Subtracting a date from a datetime (or the reverse)

Wrong

python
from datetime import datetime, date

deploy_start = datetime(2026, 8, 21, 9, 0)
deploy_day = date(2026, 8, 20)
elapsed = deploy_start - deploy_day   # TypeError

Better

python
from datetime import datetime, date

deploy_start = datetime(2026, 8, 21, 9, 0)
deploy_day = date(2026, 8, 20)
elapsed = deploy_start - datetime.combine(deploy_day, datetime.min.time())

What you see: TypeError: unsupported operand type(s) for -: 'datetime.datetime' and 'datetime.date'.

Why: datetime and date are different types with no defined subtraction between them, even though a datetime is conceptually "a date plus a time". Convert the date to a datetime first with datetime.combine — usually pairing it with datetime.min.time() for midnight.

date + time vs. datetime

date and time, separately

  • +date(2026, 8, 21) — a day, no clock reading
  • +time(9, 30) — a clock reading, no day
  • +Neither alone is a point on a timeline

datetime, combined

  • datetime(2026, 8, 21, 9, 30) — both at once
  • datetime.combine(d, t) builds one from the other two
  • Only this form is a specific instant you can order or diff precisely
  • date and time, separately
    • date(2026, 8, 21) — a day, no clock reading
    • time(9, 30) — a clock reading, no day
    • Neither alone is a point on a timeline
  • datetime, combined
    • datetime(2026, 8, 21, 9, 30) — both at once
    • datetime.combine(d, t) builds one from the other two
    • Only this form is a specific instant you can order or diff precisely

The four datetime.* types — what each one holds

The four datetime.* types — what each one holds
TypeHoldsMissingdate2 - date1 gives
date(y, m, d)calendar dayno time of day, no time zonetimedelta (whole days)
time(h, mi, s)clock readingno calendar day at alltimedelta (same-day only, no tz math)
datetime(y, m, d, h, mi, s)a specific point in timetime zone, unless tzinfo= is passedtimedelta (exact duration)
timedelta(days=, hours=, ...)a duration/spanno start or end point of its ownn/a — it is already a difference

Together

python
from datetime import date, time, datetime, timedelta

d = date(2026, 8, 21)
t = time(9, 30)
dt = datetime(2026, 8, 21, 9, 30)
delta = timedelta(days=7, hours=2)

print(d, type(d).__name__)
print(t, type(t).__name__)
print(dt, type(dt).__name__)
print(delta, type(delta).__name__)
print(dt + delta)
print(datetime.combine(d, t))

Remember: date is a day, time is a clock reading, datetime is both together as one instant, and timedelta is a duration you add to or subtract from any of the first three — but never mix a bare date with a datetime in arithmetic.

See also: datetime and zoneinfo · naive vs aware datetimes · timezones utc and zoneinfo

Time zones, UTC, and zoneinfo

standardintermediate

UTC is the one time reference that never shifts for daylight saving; zoneinfo.ZoneInfo("Region/City") attaches a real, named time zone to a datetime, and .astimezone() converts one specific instant between zones correctly.

Think of it as

UTC is the ruler everyone measures against — it never moves. A named zone like America/New_York is a rule for how far a local clock currently sits from that ruler, and that distance itself changes twice a year. .astimezone() reads an instant's true position on the UTC ruler and re-labels it in a different zone's current rule — it does not change what instant you are talking about, only how it is displayed.

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

datetime.now(ZoneInfo("Asia/Tokyo"))
some_utc_dt.astimezone(ZoneInfo("Europe/London"))

What we're doing: Convert one UTC meeting time to two different named zones and confirm each conversion represents the same instant.

meeting_zones.pypython
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

meeting_utc = datetime(2026, 12, 25, 15, 0, tzinfo=timezone.utc)
sf_time = meeting_utc.astimezone(ZoneInfo("America/Los_Angeles"))
tokyo_time = meeting_utc.astimezone(ZoneInfo("Asia/Tokyo"))

print("UTC:  ", meeting_utc.isoformat())
print("SF:   ", sf_time.isoformat())
print("Tokyo:", tokyo_time.isoformat())
4
meeting_utc is anchored to timezone.utc — an unambiguous instant to convert from.
5
.astimezone(ZoneInfo("America/Los_Angeles")) re-labels the same instant in Pacific time, applying whatever DST rule applies on December 25.
Output
UTC:   2026-12-25T15:00:00+00:00
SF:    2026-12-25T07:00:00-08:00
Tokyo: 2026-12-26T00:00:00+09:00

Why this works: All three lines describe the same instant — only the calendar date and clock reading differ, because each zone displays it relative to its own current offset from UTC. Tokyo is a full calendar day ahead of SF for this instant, which is why "the same meeting" can show two different dates depending on who reads it.

Hardcoding a fixed UTC offset instead of using a named zone

Wrong

python
from datetime import datetime, timedelta, timezone

# assumes America/New_York is always UTC-5 (EST)
ny_fixed = timezone(timedelta(hours=-5))
summer_meeting = datetime(2026, 7, 1, 9, 0, tzinfo=ny_fixed)
print(summer_meeting.astimezone(timezone.utc))

Better

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

summer_meeting = datetime(2026, 7, 1, 9, 0, tzinfo=ZoneInfo("America/New_York"))
print(summer_meeting.astimezone(timezone.utc))

What you see: A meeting scheduled with a fixed -5 offset comes out an hour off in summer, because New York is actually UTC-4 (EDT) from March to November.

Why: timezone(timedelta(hours=-5)) never changes, but America/New_York does — twice a year. ZoneInfo looks up the correct offset for the SPECIFIC date given; a fixed offset silently uses the wrong one for roughly eight months of the year.

UTC and zoneinfo — the conversion surface

UTC and zoneinfo — the conversion surface
CallWhat it does
timezone.utca fixed, zero-DST offset — the reference every other zone is measured from
ZoneInfo("America/New_York")a real IANA zone; its offset depends on the specific date given
dt.astimezone(ZoneInfo(...))converts an aware datetime to another zone, same instant, different label
dt.tzname()the abbreviation in effect for that instant, e.g. 'EDT' or 'EST'
dt.utcoffset()the timedelta currently separating this zone from UTC

Together

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

utc_now = datetime(2026, 8, 21, 12, 0, tzinfo=timezone.utc)
ny_time = utc_now.astimezone(ZoneInfo("America/New_York"))
print(utc_now)
print(ny_time)
print(ny_time.tzname(), ny_time.utcoffset())

Remember: UTC never shifts for DST; ZoneInfo("Region/City") does, correctly, because it looks up real DST rules — a fixed offset object does not, and drifts wrong for part of the year.

See also: datetime and zoneinfo · the four datetime types · daylight saving time · naive vs aware datetimes

Advertisement

The classic bug sources

Naive vs. aware datetimes and daylight saving time transitions — the two things that produce most real-world date/time bugs.

Naive vs. aware datetimes

coreintermediate

A naive datetime has no time zone attached, so it does not represent one specific instant. An aware datetime has a tzinfo, so it does — and Python refuses to compare or subtract a naive one against an aware one.

Think of it as

A naive datetime is a note that says "meet at 9:00" with no city written on it — useless the moment more than one time zone is involved, because 9:00 in Tokyo and 9:00 in Chicago are different instants. An aware datetime is the same note with the city added: "9:00, America/Chicago" pins it to one unambiguous point on the timeline.

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

datetime.now()                        # naive
datetime.now(timezone.utc)            # aware
naive_dt.replace(tzinfo=ZoneInfo("America/Chicago"))  # attach, no shift
aware_dt.astimezone(timezone.utc)     # convert, values shift

What we're doing: Take a naive timestamp that is known (by convention) to be local Chicago time, and attach a real zone correctly with .replace() rather than .astimezone().

attach_zone.pypython
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

# A naive value that the codebase KNOWS is Chicago local time,
# e.g. from a legacy log with no zone info recorded.
local_naive = datetime(2026, 8, 21, 9, 0)

local_aware = local_naive.replace(tzinfo=ZoneInfo("America/Chicago"))
print(local_aware.isoformat())
print(local_aware.astimezone(timezone.utc).isoformat())
6
local_naive has no tzinfo — Python has no way to know this is Chicago time; that fact only exists in the surrounding code's assumptions.
7
.replace(tzinfo=...) attaches the zone WITHOUT changing 9:0 — exactly what is needed, since the wall-clock reading was already correct for Chicago.
Output
2026-08-21T09:00:00-05:00
2026-08-21T14:00:00+00:00

Why this works: .replace(tzinfo=...) is correct here because the naive value already held the right wall-clock time for Chicago — it only needed a label, not a shift. Using .astimezone() instead would have been wrong: astimezone assumes the naive value already means something in the SYSTEM local zone and converts from there, which is rarely the intent for a naive value read from a log or a database.

Comparing a naive datetime to an aware one

Wrong

python
from datetime import datetime, timezone

naive = datetime(2026, 8, 21, 9, 0)
aware = datetime(2026, 8, 21, 9, 0, tzinfo=timezone.utc)
print(naive < aware)

Better

python
from datetime import datetime, timezone

naive = datetime(2026, 8, 21, 9, 0).replace(tzinfo=timezone.utc)
aware = datetime(2026, 8, 21, 9, 0, tzinfo=timezone.utc)
print(naive < aware)

What you see: TypeError: can't compare offset-naive and offset-aware datetimes.

Why: Python refuses to guess which time zone a naive datetime belongs to, so it cannot compute a meaningful ordering against an aware one. Attach a tzinfo first — with .replace() if the existing wall-clock value is already correct for that zone, or .astimezone() only if converting from a genuinely different zone.

A naive datetime vs. an aware one

Naive — datetime(2026, 8, 21, 9, 0)

  • +.tzinfo is None
  • +Not one specific instant — 9:00 where?
  • +Cannot be compared to an aware datetime

Aware — datetime(2026, 8, 21, 9, 0, tzinfo=timezone.utc)

  • .tzinfo is a real zone object
  • One unambiguous point on the timeline
  • Safe to compare, subtract, and convert with .astimezone()
  • Naive — datetime(2026, 8, 21, 9, 0)
    • .tzinfo is None
    • Not one specific instant — 9:00 where?
    • Cannot be compared to an aware datetime
  • Aware — datetime(2026, 8, 21, 9, 0, tzinfo=timezone.utc)
    • .tzinfo is a real zone object
    • One unambiguous point on the timeline
    • Safe to compare, subtract, and convert with .astimezone()

Naive vs. aware — what changes

Naive vs. aware — what changes
PropertyNaiveAware
.tzinfoNonea tzinfo object (e.g. ZoneInfo(...), timezone.utc)
Represents one instant?No — ambiguous without external contextYes — unambiguous
datetime.now(...)datetime.now() — no argumentdatetime.now(timezone.utc) — zone passed
Comparable to the other kind?TypeError if compared to an aware datetimeTypeError if compared to a naive datetime
Attach a zone without shifting time.replace(tzinfo=zone)n/a — already has one

Together

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

naive = datetime(2026, 8, 21, 9, 0)
aware = datetime(2026, 8, 21, 9, 0, tzinfo=timezone.utc)
print(naive.tzinfo, aware.tzinfo)
print(naive.isoformat())
print(aware.isoformat())

Remember: A naive datetime has no tzinfo and does not pin down one instant; call datetime.now(timezone.utc), never bare datetime.now(), and use .replace(tzinfo=...) to label a value versus .astimezone() to convert it.

See also: datetime and zoneinfo · timezones utc and zoneinfo · daylight saving time · iso 8601 and unix timestamps

Daylight saving time transitions

coreadvanced

A DST transition makes one wall-clock hour happen twice ("fall back", ambiguous) or skips it entirely ("spring forward", imaginary). The fold attribute on a datetime picks which of the two repeated occurrences is meant.

Think of it as

Picture the clock on the wall in America/New_York on November 1, 2026. At 2:00 AM it resets to 1:00 AM — so '1:30 AM' is displayed twice that night, once before the reset and once after, thirty minutes apart in reality but identical on the clock face. fold=0 means the first time you saw that reading; fold=1 means the second. In spring, the opposite happens: the clock jumps straight from 1:59 AM to 3:00 AM, so '2:30 AM' is never displayed at all — Python will still construct that datetime, but it does not correspond to a real moment the clock ever showed.

python
from datetime import datetime
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")
datetime(2026, 11, 1, 1, 30, fold=0, tzinfo=tz)   # first 1:30 AM (EDT)
datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=tz)   # second 1:30 AM (EST)

What we're doing: Prove the fall-back ambiguous hour really does convert to two different UTC instants depending on fold, using the real 2026 America/New_York transition date.

fall_back.pypython
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")

# November 1, 2026 is the real fall-back date for America/New_York.
ambiguous_fold0 = datetime(2026, 11, 1, 1, 30, fold=0, tzinfo=tz)
ambiguous_fold1 = datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=tz)

print(ambiguous_fold0, ambiguous_fold0.astimezone(timezone.utc))
print(ambiguous_fold1, ambiguous_fold1.astimezone(timezone.utc))
print(ambiguous_fold0.astimezone(timezone.utc) != ambiguous_fold1.astimezone(timezone.utc))
7
fold=0 (the default) selects the FIRST time the clock reads 1:30 AM that night — while still on EDT, UTC-4.
8
fold=1 selects the SECOND 1:30 AM — after the clock has reset to EST, UTC-5.
10
Converting fold=0 to UTC gives 5:30 AM UTC.
11
Converting fold=1 to UTC gives 6:30 AM UTC — a full hour later, despite an identical wall-clock reading.
Output
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
True

Why this works: Both datetimes print the identical wall-clock reading, '01:30:00', because that is genuinely what the clock showed twice that night. fold is the only piece of information that tells zoneinfo which of the two real instants is meant — without it, '2026-11-01 01:30:00 America/New_York' is genuinely ambiguous, and defaulting to fold=0 is a silent choice, not a neutral one.

Assuming timedelta arithmetic across a DST boundary preserves elapsed real time

Wrong

python
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")
before_dst = datetime(2026, 3, 7, 9, 0, tzinfo=tz)   # day before spring-forward
plus_one_day = before_dst + timedelta(days=1)
print(plus_one_day)                                   # looks like a normal +1 day
# but only 23 real hours actually passed:
print(plus_one_day.astimezone(timezone.utc) - before_dst.astimezone(timezone.utc))

Better

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")
before_dst = datetime(2026, 3, 7, 9, 0, tzinfo=tz)
# state intent explicitly instead of relying on +timedelta(days=1):
next_day_same_wall_time = before_dst.replace(day=before_dst.day + 1)
elapsed_utc = next_day_same_wall_time.astimezone(timezone.utc) - before_dst.astimezone(timezone.utc)
print(elapsed_utc)  # still 23:00:00 -- the point is to make this explicit, not surprising

What you see: Adding timedelta(days=1) across the March 8, 2026 spring-forward keeps the wall-clock time unchanged (9:00 AM to 9:00 AM), but only 23 real hours pass, not 24 — a scheduled job "run every 24 hours" silently drifts by an hour twice a year.

Why: timedelta arithmetic on an aware datetime preserves the wall-clock reading, not the elapsed real-world duration, whenever the addition crosses a DST boundary — zoneinfo re-normalizes the offset after the add. Code that assumes +timedelta(days=1) always means "24 real hours later" is wrong exactly twice a year, on the two transition dates.

America/New_York, November 1, 2026 — the fall-back hour repeats
  1. 1:00 AM EDT

    First 1:00 AM

    fold=0 begins — UTC-4

  2. 1:59 AM EDT

    Clock about to reset

    still fold=0, still UTC-4

  3. 1:00 AM EST

    Clock resets to 1:00 AM again

    fold=1 begins — UTC-5

  4. 1:59 AM EST

    Second 1:59 AM

    fold=1 ends; 2:00 AM EST follows normally

  1. 1:00 AM EDT: First 1:00 AM — fold=0 begins — UTC-4
  2. 1:59 AM EDT: Clock about to reset — still fold=0, still UTC-4
  3. 1:00 AM EST: Clock resets to 1:00 AM again — fold=1 begins — UTC-5
  4. 1:59 AM EST: Second 1:59 AM — fold=1 ends; 2:00 AM EST follows normally

DST transitions in America/New_York, 2026 — real, computed dates

DST transitions in America/New_York, 2026 — real, computed dates
TransitionDateWall clock doesfold behavior
Spring forward2026-03-08, 2:00 AMjumps to 3:00 AM — 2:00-2:59 AM never happensfold ignored; the reading is imaginary either way
Fall back2026-11-01, 2:00 AMresets to 1:00 AM — 1:00-1:59 AM happens twicefold=0 is the first pass (EDT), fold=1 is the second (EST)

Together

python
from datetime import datetime
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")

# Fall-back: 1:30 AM on Nov 1, 2026 happens twice
first_pass = datetime(2026, 11, 1, 1, 30, fold=0, tzinfo=tz)
second_pass = datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=tz)
print(first_pass.utcoffset(), second_pass.utcoffset())
print(first_pass.astimezone(ZoneInfo("UTC")))
print(second_pass.astimezone(ZoneInfo("UTC")))

Remember: Fall-back repeats an hour (disambiguate with fold=0/fold=1); spring-forward skips an hour entirely (that reading is imaginary, even though Python will still construct it) — and timedelta(days=1) across either boundary is not always 24 real hours.

See also: naive vs aware datetimes · timezones utc and zoneinfo · datetime and zoneinfo

Advertisement

Interchange formats

The two standard ways to move a datetime across a process boundary — human-readable ISO 8601 text and numeric Unix timestamps.

ISO 8601, Unix timestamps, and date serialization

standardintermediate

ISO 8601 ("2026-08-21T14:30:00+00:00") is the standard human-readable text format for a datetime; a Unix timestamp is the same instant as a single number — seconds since 1970-01-01 UTC. Both round-trip through .isoformat()/.fromisoformat() and .timestamp()/.fromtimestamp().

Think of it as

ISO 8601 and Unix timestamps are two different encodings of the same idea — one instant in time — the way a date can be written "August 21, 2026" or as a day-count since some epoch. ISO 8601 is for humans and logs; a Unix timestamp is for compact storage and cross-language math, since it is just one number, not a string to parse.

python
from datetime import datetime, timezone

datetime.fromisoformat("2026-08-21")           # date-only ISO string works too
dt.timestamp()                                  # -> float, seconds since epoch
datetime.fromtimestamp(ts, tz=timezone.utc)     # always pass tz explicitly

What we're doing: Serialize an aware datetime into a JSON payload with ISO 8601, then parse it back and confirm the round trip is exact.

serialize_datetime.pypython
import json
from datetime import datetime, timezone

dt = datetime(2026, 8, 21, 14, 30, 0, tzinfo=timezone.utc)
payload = {"event": "deploy", "at": dt.isoformat()}

text = json.dumps(payload)
loaded = json.loads(text)
restored = datetime.fromisoformat(loaded["at"])

print(text)
print(restored, restored == dt)
5
dt.isoformat() turns the datetime into a plain string — the only form json.dumps can serialize, since JSON has no datetime type.
9
datetime.fromisoformat() parses the string back into a datetime object with the same offset, so restored == dt holds exactly.
Output
{"event": "deploy", "at": "2026-08-21T14:30:00+00:00"}
2026-08-21 14:30:00+00:00 True

Why this works: ISO 8601 is the standard because it sorts correctly as plain text, is unambiguous across locales (unlike "08/09/2026"), and every mainstream language can parse it. Storing the offset (+00:00) in the string itself means the restored datetime is aware, not naive — the round trip loses nothing.

Calling fromtimestamp() without tz=, assuming it means UTC

Wrong

python
from datetime import datetime

ts = 1755781800.0  # from a database column, known to be UTC seconds
event_time = datetime.fromtimestamp(ts)  # naive -- silently uses SYSTEM local zone
print(event_time)

Better

python
from datetime import datetime, timezone

ts = 1755781800.0
event_time = datetime.fromtimestamp(ts, tz=timezone.utc)  # aware, explicit UTC
print(event_time)

What you see: datetime.fromtimestamp(ts) returns a naive datetime shifted into whatever time zone the RUNNING MACHINE is configured for — correct on one server, silently wrong on another with a different system zone.

Why: A Unix timestamp itself has no time zone — it is just a count of seconds since the epoch. fromtimestamp(ts) without tz= has to pick something to display it in, and defaults to the system local zone, which is rarely what a distributed service wants. Passing tz=timezone.utc makes the conversion explicit and reproducible on every machine.

ISO 8601 and Unix timestamps — converting both ways

ISO 8601 and Unix timestamps — converting both ways
DirectionCall
datetime → ISO 8601 stringdt.isoformat()
ISO 8601 string → datetimedatetime.fromisoformat(s)
aware datetime → Unix timestampdt.timestamp()
Unix timestamp → aware datetimedatetime.fromtimestamp(ts, tz=timezone.utc)
datetime → custom stringdt.strftime(fmt)

Together

python
from datetime import datetime, timezone

dt = datetime(2026, 8, 21, 14, 30, 0, tzinfo=timezone.utc)
print(dt.isoformat())
print(datetime.fromisoformat("2026-08-21T14:30:00+00:00"))
print(dt.timestamp())
print(datetime.fromtimestamp(dt.timestamp(), tz=timezone.utc))

Remember: Serialize a datetime as .isoformat() text (JSON has no datetime type) or .timestamp() as a number; when parsing a timestamp back, always pass tz=timezone.utc — fromtimestamp() without it silently uses the local machine's zone.

See also: naive vs aware datetimes · timezones utc and zoneinfo · json module

Advertisement