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.
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:
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:
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:
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:
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:
| Kind | Example | Store as |
|---|---|---|
| An instant that already happened | order placed, log entry, login | UTC instant (ISO text or integer) |
| A calendar date | birthday, invoice date, holiday | date / YYYY-MM-DD, no time, no zone |
| A future local appointment | meeting at 09:00 London time next March | local 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:
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
Zor 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/2026should 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/Londonis unambiguous;BST,CSTandISTeach 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:
Challenges
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.
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.
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.
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+ acceptsZ); checkutcoffset()is notNone - JSON: no date type; use a
default=function to write, convert named fields to read; testdatetimebeforedate - 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