Storing and Exchanging

Date and Time in Practice

Chapter 9 ยท Storing and Exchanging

A date that is right inside your program can still go wrong the moment it leaves it: written to a database, sent as JSON, read back by a different language on a different machine. This chapter collects the habits that keep a value intact on the way out and on the way back in: one text format, one zone for instants, a separate treatment for plain dates and for future local events, and strict checks on what you accept. Every snippet below was run with Python 3.14; the comments show what was printed.

ISO 8601 as the Wire Format

ISO 8601 is the standard way to write dates and times as text, and RFC 3339 is its stricter profile used by most web APIs. Its big advantage is that the pieces run from largest to smallest (year, month, day, hour...), so there is no day/month confusion, as Chapter 2 warned. For an instant, the form is 2026-11-02T12:00:05Z or 2026-11-02T13:00:05+01:00: the T separates date from time, and the trailing Z or offset says which zone the clock reading is in.

from datetime import datetime, date, timezone from zoneinfo import ZoneInfo t = datetime(2026, 11, 2, 12, 0, 5, 250000, tzinfo=timezone.utc) print(t.isoformat()) # 2026-11-02T12:00:05.250000+00:00 print(t.isoformat(timespec="seconds")) # 2026-11-02T12:00:05+00:00 print(t.isoformat(timespec="milliseconds")) # 2026-11-02T12:00:05.250+00:00 print(t.isoformat().replace("+00:00", "Z")) # 2026-11-02T12:00:05.250000Z for s in ("2026-11-02T12:00:05Z", "2026-11-02T13:00:05+01:00", "2026-11-02T12:00:05", "2026-11-02", "20261102T120005Z"): d = datetime.fromisoformat(s) print(repr(s), "->", d, d.tzinfo is not None) # '2026-11-02T12:00:05Z' -> 2026-11-02 12:00:05+00:00 True # '2026-11-02T13:00:05+01:00' -> 2026-11-02 13:00:05+01:00 True # '2026-11-02T12:00:05' -> 2026-11-02 12:00:05 False # '2026-11-02' -> 2026-11-02 00:00:00 False # '20261102T120005Z' -> 2026-11-02 12:00:05+00:00 True

Three things to notice. First, isoformat() writes +00:00 for UTC, so you replace it with Z yourself if the receiving side expects that spelling. Second, since Python 3.11 fromisoformat understands Z, the compact form without dashes and most other ISO variants; on older versions the Z raises an error. Third, a string with no offset comes back naive, and a bare date comes back as midnight. Neither is an instant, so a receiving function must decide what to do with them rather than silently accept them (Chapter 3).

Pick one spelling for everything you write: UTC, a fixed number of fractional digits, one choice between Z and +00:00. A consistent format can be compared as plain text, which databases and log tools rely on. Mixed offsets cannot:

a = datetime.fromisoformat("2026-11-02T13:00:05+01:00") b = datetime.fromisoformat("2026-11-02T12:00:05Z") print(a == b, a.isoformat() < b.isoformat()) # True False mixed = ["2026-11-02T13:00:00+01:00", "2026-11-02T12:30:00+00:00"] print(sorted(mixed)) # ['2026-11-02T12:30:00+00:00', '2026-11-02T13:00:00+01:00'] print(sorted(mixed, key=datetime.fromisoformat)) # ['2026-11-02T13:00:00+01:00', '2026-11-02T12:30:00+00:00']

The two values in a and b are the same instant, yet their strings compare as different, and text sorting puts 13:00+01:00 after 12:30+00:00 although it is half an hour earlier. The fix is to normalise to UTC before writing: dt.astimezone(timezone.utc).isoformat(timespec="seconds").

JSON Has No Date Type

JSON knows strings, numbers, booleans, lists, objects and null. A date therefore always travels as a string (ISO 8601) or a number (a timestamp, with the unit problems of Chapter 8), and the reader has to know which fields to convert back. Python's json refuses to guess:

import json ev = {"id": 7, "name": "Launch", "starts": t, "day": date(2026, 11, 2)} try: json.dumps(ev) except TypeError as e: print("TypeError:", e) # TypeError: Object of type datetime is not JSON serializable def encode(o): if isinstance(o, datetime): if o.tzinfo is None: raise ValueError("naive datetime") return o.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") if isinstance(o, date): return o.isoformat() raise TypeError(type(o).__name__) text = json.dumps(ev, default=encode, sort_keys=True) print(text) # {"day": "2026-11-02", "id": 7, "name": "Launch", "starts": "2026-11-02T12:00:05.250000Z"} back = json.loads(text) starts = datetime.fromisoformat(back["starts"]) day = date.fromisoformat(back["day"]) print(starts == t, day) # True 2026-11-02

Notice the order of checks in encode: datetime is a subclass of date, so it must be tested first or every datetime would be written as a bare date. The function also rejects naive datetimes with a ValueError instead of guessing a zone, so a missing zone is caught at the boundary where the cause is still obvious. On the way back, json.loads only gives you strings; converting starts and day is an explicit step, and it should be driven by a schema or a list of field names, never by "this string looks like a date".

Write timestamps as numbers only when the contract says so, and then state the unit in the field name (created_ms). A float timestamp survives a JSON round trip (1793620800.123456 came back identical), but other languages may parse every JSON number as a 32-bit or 53-bit value, so an integer count in a documented unit is safer than a float.

Dates in a Database

Server databases such as PostgreSQL and MySQL have proper date and time column types, and the names matter. A PostgreSQL timestamptz stores an instant (converted to UTC internally and shown in the session's zone), while a plain timestamp stores a clock reading with no zone, exactly the naive datetime of Chapter 3. Use the first for "when did this happen" and the second almost never. A date column is for calendar dates. SQLite has no date types at all: you choose between ISO text, an integer timestamp or a number, and you convert explicitly (the automatic converters of Python's sqlite3 module were deprecated in Python 3.12 for this reason). The same rules apply in every database, so SQLite's built-in module is enough to practise them:

import sqlite3 con = sqlite3.connect(":memory:") con.execute("CREATE TABLE ev (id INTEGER PRIMARY KEY, name TEXT, at_utc TEXT, at_epoch INTEGER)") rows = [("a", datetime(2026, 11, 2, 12, tzinfo=timezone.utc)), ("b", datetime(2026, 3, 1, 9, 30, tzinfo=ZoneInfo("America/New_York")))] for n, dt in rows: u = dt.astimezone(timezone.utc) con.execute("INSERT INTO ev (name, at_utc, at_epoch) VALUES (?,?,?)", (n, u.isoformat(timespec="seconds"), int(u.timestamp()))) for r in con.execute("SELECT name, at_utc, at_epoch FROM ev ORDER BY at_utc"): print(r) # ('b', '2026-03-01T14:30:00+00:00', 1772375400) # ('a', '2026-11-02T12:00:00+00:00', 1793620800) r = con.execute("SELECT at_utc FROM ev WHERE name='b'").fetchone()[0] print(datetime.fromisoformat(r).astimezone(ZoneInfo("America/New_York"))) # 2026-03-01 09:30:00-05:00

Text in a fixed UTC format sorts and range-queries correctly with ordinary string comparison, and stays readable when you inspect the table by hand; an integer is more compact and equally sortable but opaque to the eye. Either is fine, provided the whole table uses the same one. Notice how a ranged query uses a half-open interval, the pattern from Chapter 7:

q = "SELECT name FROM ev WHERE at_utc >= ? AND at_utc < ?" print(con.execute(q, ("2026-03-01T00:00:00+00:00", "2026-04-01T00:00:00+00:00")).fetchall()) # [('b',)]

Keep the boundaries in the same format as the stored values (the same offset spelling and the same number of fractional digits), otherwise the text comparison is no longer reliable. Remember also that "all of March" depends on whose March: build the boundaries from the user's zone, then convert them to UTC, as Chapter 6 and Chapter 7 did.

Three Kinds of Date

Not every date is an instant, and forcing them all into UTC is a common source of bugs. Decide which of the three each field is:

KindExampleStore as
An instant that already happenedorder placed, log entry, loginUTC instant (ISO text or integer)
A calendar datebirthday, invoice date, holidaydate / YYYY-MM-DD, no time, no zone
A future local appointmentmeeting at 09:00 London time next Marchlocal date-time and zone name, e.g. 2027-03-28 09:00 + Europe/London

A birthday stored as midnight UTC will show as the previous day to anyone west of Greenwich, so a calendar date must stay a date. The third kind is subtler. Governments change daylight saving rules, sometimes with only weeks of notice. If you store only the converted UTC instant for a meeting booked a year ahead, and the rules then change, the instant is stale and the meeting appears at the wrong local hour. Storing the wall-clock time and the IANA zone name keeps the intent, and the instant is recomputed when it is needed:

rec = {"local": "2027-03-28T09:00:00", "zone": "Europe/London"} loc = datetime.fromisoformat(rec["local"]).replace(tzinfo=ZoneInfo(rec["zone"])) print(loc, loc.astimezone(timezone.utc)) # 2027-03-28 09:00:00+01:00 2027-03-28 08:00:00+00:00

The zone's rules are read at the moment of conversion, from whatever time zone database is installed, so updating that database updates every future meeting without touching your data. Past events are the opposite: they happened at a fixed instant, so UTC is final and no zone name is needed (you may still keep the user's zone for display).

Rules for APIs

  • Send and store instants in UTC, as ISO 8601 / RFC 3339 text with an explicit Z or offset, with the same precision everywhere.
  • Reject, do not guess. A string with no offset, a bare date where an instant is expected, or a format like 02/11/2026 should produce a clear error. A permissive parser turns a client's mistake into silently wrong data.
  • Document each field's kind and unit: instant, calendar date, or local time plus zone; seconds or milliseconds for numbers.
  • Convert for display last, in the user's zone, at the edge of the system. Everything inside stays in UTC.
  • Send zone names, not abbreviations. Europe/London is unambiguous; BST, CST and IST each mean more than one thing.
  • Test the boundaries: midnight, end of month, a daylight saving change, a leap day and a timestamp near a limit, as in earlier chapters.

A small validator that follows the second rule makes the behaviour concrete. It accepts anything with an offset, converts it to UTC, and says why it rejects the rest:

def parse_instant(text): try: dt = datetime.fromisoformat(text) except ValueError: raise ValueError("not an ISO 8601 date-time") from None if dt.utcoffset() is None: raise ValueError("missing offset") return dt.astimezone(timezone.utc) for s in ("2026-11-02T13:00:00+01:00", "2026-11-02T12:00:00", "02/11/2026 12:00"): try: print(s, "->", parse_instant(s).isoformat()) except ValueError as e: print(s, "-> rejected:", e) # 2026-11-02T13:00:00+01:00 -> 2026-11-02T12:00:00+00:00 # 2026-11-02T12:00:00 -> rejected: missing offset # 02/11/2026 12:00 -> rejected: not an ISO 8601 date-time

Challenges

Exercise 1

Write dumps(obj) and loads(text, instants, days) for JSON. dumps writes aware datetimes as UTC text ending in Z (to the second), writes dates as YYYY-MM-DD, refuses naive datetimes with a ValueError, and raises a TypeError for anything else it cannot encode. loads converts the named fields back. Round-trip an order placed at 09:30 New York time on 2026-11-02 with a ship date of 2026-11-05, print the JSON and the converted values, and say whether the round-tripped moment equals the original and whether it keeps the original offset. Predict the JSON first.

๐Ÿ“„ View solution
Exercise 2

Build an in-memory SQLite table of meetings with the columns title, at_utc (ISO text in UTC) and zone. Add four meetings given as local times in four zones, list them ordered by at_utc, select the ones that fall in December 2026 (UTC) using a half-open range, and print each meeting in its own zone. Finally show that three timestamps with different offsets sort wrongly as text but correctly with a key function. Predict the stored UTC values and which meetings fall in December before you run it.

๐Ÿ“„ View solution
Exercise 3

Part A: write parse_instant so it returns UTC for strings with an offset or Z, and rejects, with a reason, strings with no offset, a bare date, a day/month/year string, and an impossible month. Run it on six inputs. Part B: for a meeting booked as local 2027-03-28 09:00 in Europe/London, compute the UTC instant from the local time and zone, and then show what you would get if the zone's rules were changed to stay on UTC all year. Explain why storing local time plus zone survives that change while a stored UTC instant does not.

๐Ÿ“„ View solution

Quick Reference

  • Format: ISO 8601 / RFC 3339, 2026-11-02T12:00:05Z; one spelling and one precision everywhere
  • Write: dt.astimezone(timezone.utc).isoformat(timespec="seconds"); naive values are rejected, not guessed
  • Read: datetime.fromisoformat(s) (Python 3.11+ accepts Z); check utcoffset() is not None
  • JSON: no date type; use a default= function to write, convert named fields to read; test datetime before date
  • Databases: use a zone-aware instant column; SQLite needs explicit ISO text or integers; query with half-open ranges
  • Three kinds: instant (UTC), calendar date (date), future local appointment (local time + IANA zone name)
  • Mixed offsets: compare as datetimes, never as text
  • APIs: UTC on the wire, zone names not abbreviations, convert to local time only for display
Coming next
Date and Time in Practice 10 is the capstone: a scheduling toolkit for recurring events across time zones, built from everything in the course and tested.