Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Data Formats (JSON, YAML, TOML, XML, CSV)  Module 10 – Across formats: time, conversion, streaming, evolution, binary

Dates and times across data formats

Write timestamps every parser reads alike with RFC 3339, see what each format returns for a date, attach time zones on purpose and read DD/MM/YYYY safely.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Write timestamps that every parser reads the same way, using RFC 3339
  • Explain which formats have native date types and what each parser returns
  • Detect local times without offsets and attach the right zone deliberately
  • Convert Indian DD/MM/YYYY text to ISO 8601 dates without ambiguity

Before you start

What you’ll learn · Before you start

What you will learn

  • Write timestamps that every parser reads the same way, using RFC 3339
  • Explain which formats have native date types and what each parser returns
  • Detect local times without offsets and attach the right zone deliberately
  • Convert Indian DD/MM/YYYY text to ISO 8601 dates without ambiguity

Before you start

On this page

A clinic in Pune books an appointment for 10:30. Read by a program on a computer in India the time is right, but read by the same program on a server set to UTC, or by Ruby’s YAML parser anywhere, it turns into 16:00 in Pune, five and a half hours late, and nothing reports an error.

Dates and times break between formats because most of them have no date type at all, and the ones that do disagree on what a time without an offset means. You will check timestamps against RFC 3339, see what JSON, YAML, TOML, XML and CSV parsers return for the same appointment, attach time zones on purpose, and convert day-first Indian dates without guessing.

An RFC 3339 timestamp needs a date, a time and an offset

2026-10-04T10:30:00+05:30 and 2026-10-04T05:00:00Z name the same instant: half past ten in India is five o’clock UTC. Both follow RFC 3339: The IETF's profile of ISO 8601 for timestamps on the internet: a full date, the letter T, a time with seconds, an optional fraction and an offset, Z or a sign with hours and minutes. JSON Schema's date-time format refers to its grammar., the internet’s profile of ISO 8601. Its grammar, in section 5.6, writes a full date, the letter T, a time with seconds, an optional fraction and a UTC offset: How far a local clock is ahead of or behind UTC, written Z or as a sign with hours and minutes, such as +05:30 for India. An offset fixes one instant but says nothing about the rules of the place., which is Z or a sign with hours and minutes. Step through the six parts:

Interactive · stage by stage The parts of one RFC 3339 timestamp, left to right Step through the six parts of a timestamp. Each step says what the part holds, and what a reader does without it.
The parts of one RFC 3339 timestamp, left to right, step 6 of 6. Showing full-date, The letter T, partial-time, time-secfrac, time-offset, Suffix of RFC 9557. In focus: Suffix of RFC 9557.full-dateYYYY-MM-DDThe letter Tjoins the date and timepartial-timeHH:MM:SS, seconds neededtime-secfracoptional: .250time-offsetZ or +05:30Suffix of RFC 9557optional: [Asia/Kolkata]

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The date. A timestamp starts with the full date: a year of four digits, a month and a day of two digits each, joined by hyphens. ISO 8601 also allows week dates and days of the year; RFC 3339 does not.
  2. The separator. An upper-case T joins the date to the time, and the grammar also accepts a lower-case t. A space is a choice an application may make for readability, outside the grammar, so a strict validator refuses it.
  3. The time. Hours from 00 to 23, then minutes, then seconds, which may not be left out. The seconds may read 60 at the end of a month that has a leap second, a value both Python and JavaScript refuse.
  4. The fraction. A full stop and any number of digits. The grammar sets no limit, but Python keeps 6 digits and the Date of JavaScript keeps 3, so a timestamp written with 9 comes back shorter.
  5. The offset. Z, or a sign with hours and minutes, as +05:30 for India. Without it the text is only the reading of some clock, not one instant, and each reader guesses whose clock it was.
  6. The zone's name. RFC 9557 adds an optional suffix in brackets with the name of the time zone. The offset still fixes the instant; the name says which rules apply when you add a day or a month in that place.

Two rules sit beside the grammar. Section 5.7 sets the ranges: a day the month really has, hours to 23, and seconds up to 60 for a leap second: A second added to UTC as second 60 of the last minute of a month, to keep UTC close to the Earth's rotation. RFC 3339 allows it; Python's datetime and JavaScript's Date both refuse it.. And a note allows a lower-case t and z, and lets an application put a space between the date and the time for readability. A space is the application’s choice, not the grammar: JSON Schema’s date-time format refers to the grammar, so a strict validator refuses it. I-JSON, RFC 7493 section 4.3, narrows the choice for JSON: upper-case letters, a zone that is stated rather than assumed, and seconds even when they are zero.

RFC 9557 adds two things. A suffix in brackets names the time zone, as in 2026-10-04T10:30:00+05:30[Asia/Kolkata], and a ! just inside the bracket, [!Asia/Kolkata], makes it critical, so a reader that cannot use it must refuse the string. It also updates the meaning of Z: it now says the time in UTC is known and the local offset is not, the meaning RFC 3339 had given to -00:00, while +00:00 still says that UTC is the preferred reference point.

Is a parser a validator? This program checks twelve timestamps against the grammar and its ranges, against I-JSON’s advice, and with Python’s datetime.fromisoformat:

Twelve timestamps: the grammar, I-JSON and Python Python · stamps/rfc3339_check.py
"""Twelve timestamps checked against the RFC 3339 grammar, against I-JSON's stricter advice, and by Python's parser."""
import calendar
import re
from datetime import datetime, timezone

# RFC 3339 section 5.6: date-time = full-date "T" full-time; "T" and "Z" may also be lower case.
RFC3339 = re.compile(
    r"(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-](\d{2}):(\d{2}))"
)


def rfc3339(text):
    """True when text follows RFC 3339's grammar and its value ranges (section 5.7)."""
    m = RFC3339.fullmatch(text)
    if not m:
        return False
    year, month, day, hour, minute, second = (int(g) for g in m.groups()[:6])
    if not 1 <= month <= 12 or not 1 <= day <= calendar.monthrange(year, month)[1]:
        return False
    if hour > 23 or minute > 59 or second > 60:  # 60 only for a leap second
        return False
    if m.group(9) and (int(m.group(9)) > 23 or int(m.group(10)) > 59):
        return False
    return True


def i_json(text):
    """RFC 7493 section 4.3 adds: upper-case letters, and seconds even when they are zero."""
    return rfc3339(text) and text == text.upper()


def python_reads(text):
    try:
        value = datetime.fromisoformat(text)
    except ValueError as e:
        return "ValueError: " + str(e).split(":")[0]
    if value.tzinfo is None:
        return "naive " + value.isoformat()
    return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")


STAMPS = [
    "2026-10-04T10:30:00+05:30",
    "2026-10-04T05:00:00Z",
    "2026-10-04t05:00:00z",
    "2026-10-04T10:30:00.123456789+05:30",
    "2026-10-04 10:30:00+05:30",
    "2026-10-04T10:30:00",
    "2026-10-04T10:30+05:30",
    "20261004T103000+0530",
    "2026-10-04T10:30:00+05:30[Asia/Kolkata]",
    "2026-02-30T10:00:00Z",
    "2026-10-04T24:00:00Z",
    "2016-12-31T23:59:60Z",
]

print(f"{'text':<41}{'RFC 3339':<10}{'I-JSON':<8}Python 3.14 fromisoformat")
for stamp in STAMPS:
    print(f"{stamp:<41}{'yes' if rfc3339(stamp) else 'no':<10}{'yes' if i_json(stamp) else 'no':<8}{python_reads(stamp)}")

Output

text                                     RFC 3339  I-JSON  Python 3.14 fromisoformat
2026-10-04T10:30:00+05:30                yes       yes     2026-10-04T05:00:00Z
2026-10-04T05:00:00Z                     yes       yes     2026-10-04T05:00:00Z
2026-10-04t05:00:00z                     yes       no      ValueError: Invalid isoformat string
2026-10-04T10:30:00.123456789+05:30      yes       yes     2026-10-04T05:00:00.123456Z
2026-10-04 10:30:00+05:30                no        no      2026-10-04T05:00:00Z
2026-10-04T10:30:00                      no        no      naive 2026-10-04T10:30:00
2026-10-04T10:30+05:30                   no        no      2026-10-04T05:00:00Z
20261004T103000+0530                     no        no      2026-10-04T05:00:00Z
2026-10-04T10:30:00+05:30[Asia/Kolkata]  no        no      ValueError: Invalid isoformat string
2026-02-30T10:00:00Z                     no        no      ValueError: day 30 must be in range 1..28 for month 2 in year 2026
2026-10-04T24:00:00Z                     no        no      2026-10-05T00:00:00Z
2016-12-31T23:59:60Z                     yes       yes     ValueError: second must be in 0..59, not 60

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rfc3339_check.py

Python and the grammar disagree on 7 of the 12. fromisoformat accepts the space, the missing seconds, the compact 20261004T103000+0530 and the hour 24, none of which RFC 3339 allows, and it refuses the lower-case z and the leap second, which RFC 3339 allows. The lower-case t was not the problem: its documentation lets any single character stand between the date and the time. It also returns a naive datetime, one with no zone, for the time without an offset instead of an error, and keeps 6 of the 9 fraction digits.

Validating with a parser

Wrong: accepting a timestamp because datetime.fromisoformat or Date.parse read it. Both accept forms RFC 3339 does not, and both read a missing offset as some clock. Right: check the text against the RFC 3339 grammar first, as rfc3339() does, and refuse anything without an offset before you parse it.

Checkpoint

Check: RFC 3339 timestamps

  1. Question 1 of 2 Which of these four timestamps is valid RFC 3339 and also follows the advice of I-JSON?

    Read the code, then choose one answer.

    a = "2026-10-04 10:30:00+05:30"
    b = "2026-10-04T10:30+05:30"
    c = "2026-10-04t05:00:00z"
    d = "2026-10-04T05:00:00Z"
    Show the answer: Question 1 of 2

    Answer: d only

    Only d passes both. The lesson's program marked a and b "no" for RFC 3339, and c "yes" for RFC 3339 but "no" for I-JSON.

  2. Question 2 of 2 Since RFC 9557 updated RFC 3339, what does an offset of Z say about a timestamp?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The time in UTC is known, and the local offset is not

    Write Z for instants recorded in UTC, and the local offset, such as +05:30, when readers need the local time.

Each format returns a date type or plain text

Here is one appointment written in five formats, each with a date, a time with its offset, and a time without one:

One appointment in five formats

JSON · formats/appointment.json

{"day": "2026-10-04", "at": "2026-10-04T10:30:00+05:30", "local": "2026-10-04T10:30:00"}

TOML · formats/appointment.toml

# One clinic appointment in Pune, written three ways
day = 2026-10-04
at = 2026-10-04T10:30:00+05:30
local = 2026-10-04T10:30:00

YAML · formats/appointment.yaml

# The same appointment in YAML: the three values are written plain, without quotes
day: 2026-10-04
at: 2026-10-04T10:30:00+05:30
local: 2026-10-04 10:30:00

XML · formats/appointment.xml

<appointment day="2026-10-04">
  <at>2026-10-04T10:30:00+05:30</at>
  <local>2026-10-04T10:30:00</local>
</appointment>

CSV · formats/appointment.csv

day,at,local
2026-10-04,2026-10-04T10:30:00+05:30,2026-10-04T10:30:00

Python’s standard library reads four of them. This program prints what each parser hands back, then applies XML Schema’s rule for ordering a time without an offset against one with an offset:

What JSON, TOML, CSV and XML parsers return for dates Python · formats/read_dates.py
"""One appointment written in JSON, TOML, CSV and XML: what Python's standard parsers hand back for its three values."""
import csv
import json
import tomllib
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone

with open("appointment.json", encoding="utf-8") as f:
    from_json = json.load(f)
with open("appointment.toml", "rb") as f:
    from_toml = tomllib.load(f)
with open("appointment.csv", encoding="utf-8", newline="") as f:
    from_csv = next(csv.DictReader(f))
root = ET.parse("appointment.xml").getroot()
from_xml = {"day": root.get("day"), "at": root.findtext("at"), "local": root.findtext("local")}

print(f"{'format':<7}{'key':<7}{'Python type':<13}value")
for name, record in [("JSON", from_json), ("TOML", from_toml), ("CSV", from_csv), ("XML", from_xml)]:
    for key in ("day", "at", "local"):
        value = record[key]
        zone = getattr(value, "tzinfo", "")
        shown = value.isoformat() if hasattr(value, "isoformat") else repr(value)
        extra = "" if zone == "" else f"  tzinfo={zone}"
        print(f"{name:<7}{key:<7}{type(value).__name__:<13}{shown}{extra}")


# XML Schema 1.1 orders a dateTime without an offset against one with an offset by trying the extreme offsets,
# +14:00 and -14:00, on the value that lacks one. If the two tries disagree, the values are incomparable.
def xsd_order(no_offset, with_offset):
    tries = set()
    for hours in (14, -14):
        imputed = no_offset.replace(tzinfo=timezone(timedelta(hours=hours)))
        tries.add("less" if imputed < with_offset else "greater" if imputed > with_offset else "equal")
    return tries.pop() if len(tries) == 1 else "incomparable"


local = datetime.fromisoformat("2026-10-04T10:30:00")
for other in ["2026-10-04T05:00:00Z", "2026-10-05T05:00:00Z", "2026-10-02T05:00:00Z"]:
    print(f"xs:dateTime 2026-10-04T10:30:00 vs {other}: {xsd_order(local, datetime.fromisoformat(other))}")

Output

format key    Python type  value
JSON   day    str          '2026-10-04'
JSON   at     str          '2026-10-04T10:30:00+05:30'
JSON   local  str          '2026-10-04T10:30:00'
TOML   day    date         2026-10-04
TOML   at     datetime     2026-10-04T10:30:00+05:30  tzinfo=UTC+05:30
TOML   local  datetime     2026-10-04T10:30:00  tzinfo=None
CSV    day    str          '2026-10-04'
CSV    at     str          '2026-10-04T10:30:00+05:30'
CSV    local  str          '2026-10-04T10:30:00'
XML    day    str          '2026-10-04'
XML    at     str          '2026-10-04T10:30:00+05:30'
XML    local  str          '2026-10-04T10:30:00'
xs:dateTime 2026-10-04T10:30:00 vs 2026-10-04T05:00:00Z: incomparable
xs:dateTime 2026-10-04T10:30:00 vs 2026-10-05T05:00:00Z: less
xs:dateTime 2026-10-04T10:30:00 vs 2026-10-02T05:00:00Z: greater

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 read_dates.py

JSON has no date type in its data model, so all three values arrive as strings, and so do the CSV fields and the XML text. TOML is the only one of the four with types of its own, as the TOML module showed: a local date, an offset date-time: A TOML date and time with its offset from UTC, such as 10:00 at +05:30. It names one instant, the same on every computer, and Python's tomllib returns it as an aware datetime. More in “TOML 1.1, dates, integers and parsers” that comes back as an aware datetime: A Python datetime whose tzinfo is set, so it names one instant. A naive datetime has no tzinfo, and Python refuses to compare the two kinds for order. More in “TOML 1.1, dates, integers and parsers”, and a local date-time: A TOML date and time with no offset. It is a reading of a wall clock, not an instant: computers in two time zones turn it into two different instants. More in “TOML 1.1, dates, integers and parsers” that comes back naive, with tzinfo=None.

XML text becomes a date only when a schema types it. XML Schema’s xs:dateTime makes the offset optional, and it orders a value without one by trying the two extreme offsets, +14:00 and -14:00: when the two tries disagree, the values are incomparable. The last three lines show it: incomparable against the instant on the same day, less and greater against the instants a day or more away. XPath instead compares such a value with the processor’s implicit time zone, so the answer depends on how the processor was set up.

YAML depends on the schema. The 1.2 core schema: The schema that YAML 1.2.2 recommends as the default. Plain scalars may be null, booleans written true or false in three spellings, integers in base 10, 0o octal or 0x hex, or floats; anything else is a string. More in “YAML tags and schemas: failsafe, JSON, core” has no timestamp tag, so a parser that follows it returns all three values as strings. Ruby’s Psych reads YAML 1.1’s timestamp type instead:

What Psych returns for the YAML appointment Ruby · formats/read_dates.rb
# The appointment in YAML, read by Psych (Ruby's YAML parser, which follows YAML 1.1) on a computer in UTC and on
# one in India. Ruby reads the time zone again when ENV["TZ"] changes, so one program can play both computers.
require "yaml"
require "date"

text = File.read("appointment.yaml")

begin
  YAML.safe_load(text)
rescue Psych::DisallowedClass => e
  puts "safe_load alone: #{e.message}"
end

["UTC", "Asia/Kolkata"].each do |zone|
  ENV["TZ"] = zone
  puts "TZ=#{zone}"
  YAML.safe_load(text, permitted_classes: [Date, Time]).each do |key, value|
    puts format("  %-6s %-5s %s", key, value.class, value.is_a?(Time) ? value.strftime("%Y-%m-%d %H:%M:%S %:z") : value.to_s)
  end
end

Output

safe_load alone: Tried to load unspecified class: Date
TZ=UTC
  day    Date  2026-10-04
  at     Time  2026-10-04 10:30:00 +05:30
  local  Time  2026-10-04 10:30:00 +00:00
TZ=Asia/Kolkata
  day    Date  2026-10-04
  at     Time  2026-10-04 10:30:00 +05:30
  local  Time  2026-10-04 16:00:00 +05:30

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby read_dates.rb

Psych refused to load the date at all until the program named Date and Time in permitted_classes. Then it built a Date and two Time values, and it read the time without an offset as UTC, which is what YAML 1.1’s timestamp type says to assume. On a computer in India that Time prints as 16:00 +05:30: the late appointment of the opening.

Format A date A time with an offset A time without one
JSON, CSV text text text
XML text, or xs:date with a schema text, or xs:dateTime text, or xs:dateTime without its zone
TOML local date aware datetime naive datetime
YAML 1.2 core schema text text text
YAML 1.1, as Psych reads it Date Time with its offset Time read as UTC

Key idea

Only TOML always types its dates, and YAML 1.1 types them when the loader allows it; JSON, CSV, XML without a schema and YAML’s 1.2 core schema hand back text, so the reader decides what the text means.

Checkpoint

Check: What each format returns for a date

  1. Question 1 of 2 Psych reads a plain YAML timestamp with a date and the time 10:30:00 but no offset. What does a computer set to India print for it?

    Choose one answer.

    A hint

    YAML 1.1 says what an omitted time zone means.

    Show the answer: Question 1 of 2

    Answer: 16:00 +05:30

    read_dates.rb printed 10:30 +00:00 on the computer set to UTC and 16:00 +05:30 on the one set to India: one instant, read as UTC, which is the late appointment of the opening.

  2. Question 2 of 2 An xs:dateTime has no offset. How does XML Schema 1.1 order it against an xs:dateTime that has one?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It tries +14:00 and -14:00 on the value without one, and calls the pair incomparable if the two tries disagree

    The program printed incomparable for the instant on the same day, and less and greater for the instants a day later and two days earlier.

A local reading needs a zone, attached on purpose

A time without an offset is the reading of a clock, not an instant. JavaScript’s Date turns it into an instant with the clock of the computer it runs on. This program plays a computer set to UTC and one set to India:

Date.parse on a computer in UTC and on one in India JavaScript · zones/date_parse.mjs
// What JavaScript's Date makes of eight timestamps on a computer set to UTC and on one set to India (Asia/Kolkata).
// Node.js reads the time zone again when process.env.TZ changes, so one program can play both computers.
const stamps = [
  "2026-10-04T10:30:00+05:30", // offset given: one instant everywhere
  "2026-10-04T10:30:00", // date and time, no offset: read as local time
  "2026-10-04", // date only: read as UTC midnight
  "2026-10-04 10:30:00", // not ECMAScript's format: the engine guesses, as local time
  "2026-10-04t05:00:00z", // lower case, allowed by RFC 3339
  "04/10/2026", // not ECMAScript's format: read month first
  "2026-02-30T10:00:00Z", // no such day: V8 rolls it into March
  "2016-12-31T23:59:60Z", // a real leap second
];

for (const zone of ["UTC", "Asia/Kolkata"]) {
  process.env.TZ = zone;
  console.log(`TZ=${zone}`);
  for (const text of stamps) {
    const ms = Date.parse(text);
    console.log(`  ${text.padEnd(26)}${Number.isNaN(ms) ? "NaN (refused)" : new Date(ms).toISOString()}`);
  }
}

// The same instant, shown to an Indian reader: formatting needs a zone, and en-IN writes the day first.
const instant = new Date("2026-10-04T05:00:00Z");
console.log(new Intl.DateTimeFormat("en-IN", { timeZone: "Asia/Kolkata" }).format(instant));
console.log(new Intl.DateTimeFormat("en-IN", { timeZone: "Asia/Kolkata", dateStyle: "long", timeStyle: "short" }).format(instant));

// Temporal has types for dates without times, zoned date-times and RFC 9557 strings; check before using it.
console.log(`Temporal in Node.js ${process.versions.node}: ${typeof globalThis.Temporal === "undefined" ? "not available" : "available"}`);

Output

TZ=UTC
  2026-10-04T10:30:00+05:30 2026-10-04T05:00:00.000Z
  2026-10-04T10:30:00       2026-10-04T10:30:00.000Z
  2026-10-04                2026-10-04T00:00:00.000Z
  2026-10-04 10:30:00       2026-10-04T10:30:00.000Z
  2026-10-04t05:00:00z      2026-10-04T05:00:00.000Z
  04/10/2026                2026-04-10T00:00:00.000Z
  2026-02-30T10:00:00Z      2026-03-02T10:00:00.000Z
  2016-12-31T23:59:60Z      NaN (refused)
TZ=Asia/Kolkata
  2026-10-04T10:30:00+05:30 2026-10-04T05:00:00.000Z
  2026-10-04T10:30:00       2026-10-04T05:00:00.000Z
  2026-10-04                2026-10-04T00:00:00.000Z
  2026-10-04 10:30:00       2026-10-04T05:00:00.000Z
  2026-10-04t05:00:00z      2026-10-04T05:00:00.000Z
  04/10/2026                2026-04-09T18:30:00.000Z
  2026-02-30T10:00:00Z      2026-03-02T10:00:00.000Z
  2016-12-31T23:59:60Z      NaN (refused)
4/10/2026
4 October 2026 at 10:30 am
Temporal in Node.js 24.21.0: not available

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node date_parse.mjs

The ECMAScript rule is there in the first three lines of each block. With an offset, both computers agree. A date and time without one is read as local time, so the two computers store instants five and a half hours apart. A date alone is read as UTC midnight. The other forms are left to the engine: V8 read 04/10/2026 month first, rolled 2026-02-30 into March, and refused the leap second. Intl.DateTimeFormat with en-IN writes the day first, so the same instant appears as 4/10/2026. Temporal, a newer date API with types for dates, zoned date-times and RFC 9557 strings, is not in Node.js 24.21.0, so the program checks before it uses it.

Here is the same slip between two systems, with an order placed late in the evening:

Interactive · message by message One order time, written without its offset Step through an order placed at 22:00 in Pune. Watch which clock each reader assumes, and where the day changes.
One order time, written without its offset, step 4 of 4. Messages so far: Shop app in Pune to Order export: placed: 22:00, no offset; Order export to Report server: read as 22:00 UTC; Shop app in Pune to Order export: placed: 22:00+05:30.Shop app in Puneclock at +05:30Order exportJSON textReport serverstored: 16:30 UTCplaced: 22:00, no offsetread as 22:00 UTCplaced: 22:00+05:30

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. An order at 22:00. A customer in Pune pays at 22:00 on a Sunday. The app writes the time as its clock shows it and leaves out the offset, +05:30.
  2. Read as UTC. The report server runs in UTC, and its parser reads a time without an offset as local time, which on this server is UTC. The order is now stored five and a half hours later than it happened.
  3. The wrong day. The daily report shows times in India. 22:00 UTC is 03:30 on Monday in Pune, so the order is counted in the next day's total, and nothing reported an error.
  4. The offset kept. With the offset written, the same order names one instant on every computer: 16:30 UTC, which is 22:00 on Sunday in Pune, whatever the clock of the reader.

An offset fixes one instant, but it says nothing about the rules of a place. An IANA time zone: A named region such as Asia/Kolkata or Europe/London, whose history of UTC offsets and daylight-saving changes the IANA Time Zone Database records. Python's zoneinfo and JavaScript's Intl read it. such as Asia/Kolkata or Europe/London carries the history of a region’s offsets and its daylight-saving changes, and Python’s zoneinfo reads it:

Attaching zones with zoneinfo, gaps and folds included Python · zones/attach_zone.py
"""Attaching a time zone to a local date and time on purpose, with the IANA time zone database through zoneinfo."""
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

UTC = timezone.utc
kolkata = ZoneInfo("Asia/Kolkata")
london = ZoneInfo("Europe/London")

# A naive datetime is a wall-clock reading. replace(tzinfo=...) says where the wall clock hangs.
reading = datetime.fromisoformat("2026-10-04T10:30:00")
for zone in (kolkata, london):
    aware = reading.replace(tzinfo=zone)
    print(f"10:30 on that day in {zone.key:<13} = {aware.isoformat()} = {aware.astimezone(UTC):%H:%M} UTC")

# A fixed offset is right only while the zone keeps it. London changes its offset twice a year; India does not.
winter_offset = timezone(timedelta(0))
summer_day = datetime.fromisoformat("2026-07-08T10:30:00")
print("London in July with a winter offset:", summer_day.replace(tzinfo=winter_offset).astimezone(UTC).strftime("%H:%M UTC"))
print("London in July with its zone:       ", summer_day.replace(tzinfo=london).astimezone(UTC).strftime("%H:%M UTC"))

# Clocks in London jumped from 01:00 to 02:00: 01:30 never happened on that day.
gap = datetime.fromisoformat("2026-03-29T01:30:00").replace(tzinfo=london)
back = gap.astimezone(UTC).astimezone(london)
print(f"01:30 in the spring gap: offset {gap.utcoffset()}, and back from UTC it reads {back:%H:%M}")

# Clocks went back from 02:00 to 01:00: 01:30 happened twice. fold picks the first (0) or the second (1).
for fold in (0, 1):
    twice = datetime.fromisoformat("2025-10-26T01:30:00").replace(tzinfo=london, fold=fold)
    print(f"01:30 in the autumn overlap, fold={fold}: {twice.isoformat()} = {twice.astimezone(UTC):%H:%M} UTC")

Output

10:30 on that day in Asia/Kolkata  = 2026-10-04T10:30:00+05:30 = 05:00 UTC
10:30 on that day in Europe/London = 2026-10-04T10:30:00+01:00 = 09:30 UTC
London in July with a winter offset: 10:30 UTC
London in July with its zone:        09:30 UTC
01:30 in the spring gap: offset 0:00:00, and back from UTC it reads 02:30
01:30 in the autumn overlap, fold=0: 2025-10-26T01:30:00+01:00 = 00:30 UTC
01:30 in the autumn overlap, fold=1: 2025-10-26T01:30:00+00:00 = 01:30 UTC

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 attach_zone.py

India keeps +05:30 all year, so a fixed offset is exact there. London does not: a winter offset applied to a July time put it an hour late, 10:30 UTC instead of 09:30 UTC. On the night the clocks go forward, 01:30 does not exist, and Python read it with the offset from before the change. On the night they go back, 01:30 happens twice, and the fold: The attribute of a Python datetime that chooses between the two moments a local time names when clocks go back: 0 for the first, 1 for the second, as PEP 495 defines it. attribute of PEP 495 picks the first or the second.

Which form to send depends on the field. Answer three questions about a time field of your own:

Interactive · answer and compare How should a time travel in your data? Answer three questions about one time field of yours. The table marks the forms that fit and says why the others do not.
Is the field a moment that has happened, or a local time booked for later?
Must readers see the local time the person saw or chose?
Do people read the raw value, in logs or exports?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to write a time, against what decides between them
Written as Names one instant Keeps the local time Survives a rule change People can read it
RFC 3339, local offset Holds: the local time and its offset Good 10:30+05:30 is 05:00 UTC on every computer Good the reader still sees 10:30, as the clock in Pune showed it Mixed an offset chosen today is wrong for a future time if the zone changes its rules first Good readable, and the offset says where it was written
RFC 3339 in UTC Holds: UTC, marked Z Good Z: one instant, and every value sorts as text Poor the local 10:30 is gone; showing it again needs the zone, which is not kept Mixed right for events that happened; a future local time drifts if the rules change Mixed readable, but in UTC rather than the local clock
Local time and zone name Holds: the wall time and a name such as [Europe/London] Mixed an instant only once the zone database turns the wall time into one Good the time the person chose, 10:30, exactly as chosen Good the instant is worked out with the rules in force at the time Good readable, and the zone name says which rules apply
Unix seconds Holds: a count of seconds Good one instant as a whole number, such as 1791090000 Poor no local time and no zone Mixed as for UTC: fine for the past, drifts for a booked local time Poor a person needs a converter to read 1791090000
Local time alone Holds: the wall time, nothing else Poor every reader guesses the zone, as the report server did Good the wall time is there Poor nothing says which zone, so no rule can be applied Good readable, and silently wrong elsewhere
How the answers decide
  • “Is the field a moment that has happened, or a local time booked for later?” It has happened: rules out Local time alone. A past event is one instant; without an offset every reader guesses which one, as the report server did.
  • “Is the field a moment that has happened, or a local time booked for later?” A local time booked for later: rules out Local time alone. Without the zone, nobody can work out the instant of a booked local time later.
  • “Is the field a moment that has happened, or a local time booked for later?” A local time booked for later: adds a caveat to RFC 3339, local offset, RFC 3339 in UTC and Unix seconds. If the zone changes its rules before the booking, a stored instant no longer matches the local time the person chose.
  • “Must readers see the local time the person saw or chose?” Yes: rules out RFC 3339 in UTC and Unix seconds. Both drop the local time, and showing it again needs the zone, which they do not keep.
  • “Do people read the raw value, in logs or exports?” Yes: adds a caveat to Unix seconds. A count of seconds is exact, but nobody reads it without a converter.

A moment that has happened travels as RFC 3339 with its offset, or in UTC. A local time booked for later, such as a clinic slot next year, travels as the local time with the zone’s name, because a government may change the zone’s rules before then, and the person still expects their 10:30.

Checkpoint

Check: Offsets, zones and local readings

  1. Question 1 of 2 This runs on a server whose clock is set to UTC. What does it print?

    Read the code, then choose one answer.

    console.log(new Date("2026-10-04T10:30:00").toISOString());
    console.log(new Date("2026-10-04").toISOString());
    Show the answer: Question 1 of 2

    Answer: Times of 10:30:00.000Z on the first line and 00:00:00.000Z on the second

    date_parse.mjs printed this on its UTC computer. On the computer set to India the first line moved to 05:00, and the second did not move.

  2. Question 2 of 2 A clinic in London books a check-up for 09:00 local time, eight months ahead. How should the booking store it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The local time with the zone name Europe/London

    The trade-off table ruled out the bare local time and put a caveat on every stored instant for bookings in the future. A booked local time travels with its zone's name, the form RFC 9557's suffix can carry.

Day-first dates need a parser that is told the order

04/10/2026 is the fourth of October in India and the tenth of April in the United States, and en-IN formatting writes the day first, as the JavaScript program printed. A parser that guesses reads it month first, as V8 did. The safe reader is told the order, in a format string such as %d/%m/%Y or a pattern that names each part, and it refuses every other form.

Dates in an e-invoice acknowledgement

The official sample response of India’s e-invoice system holds two date forms. AckDt is 2020-08-05 22:55:00, with a space instead of T and no offset, so the zone, India’s +05:30, is known only because the portal is in India. The signed invoice inside it carries "Dt": "05/08/2020", day first. Neither is RFC 3339, and a reader that guesses gets both wrong.

Follow the two fields through a reader that never guesses:

Interactive · stage by stage Two fields of an acknowledgement, written as RFC 3339 Step through AckDt and the invoice's Dt. Each step names the rule that decides, and the last one says what is written.
Two fields of an acknowledgement, written as RFC 3339, step 6 of 6. Showing The field's text (Dt: day first, a date only), Match its exact form, Check the day exists, Attach the zone (Dt: a date, no zone), Write the standard form. In focus: Check the day exists, refused.refused, with the fieldThe field's textDt: day first, a date onlyMatch its exact formone pattern per formCheck the day exists31/02 is refusedAttach the zoneDt: a date, no zoneWrite the standard formRFC 3339 or an ISO date

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. AckDt. AckDt is written year, month, day, a space and a time with seconds, and no offset. Its pattern names each part, so a value in any other form is refused instead of guessed.
  2. A real day. strptime builds the date and refuses a day the month does not have. In JavaScript, Date.UTC would roll such a day into the next month, so the code compares the parts it got back.
  3. The zone, on purpose. The portal's clock is in India, which keeps +05:30 all year, so the code attaches that offset. Nothing in the text says so: the zone comes from what the sender documents.
  4. Written out. The value is written with its offset, 22:55+05:30, and in UTC, 17:25Z. Every reader now gets the same instant.
  5. The invoice's Dt. The invoice date is day first, two digits each and a year of four. The format string says so, %d/%m/%Y, and a date needs no zone, so it is written as an ISO date.
  6. Refused. A text that matches no pattern, or names a day that does not exist, goes back with the field's name, so the sender can correct the record.

Here are the fields of the sample response that this program reads, with the invoice’s DocDtls taken from its signed part:

Fields of the sample acknowledgement JSON · india/ack.json
{
  "AckNo": 112010000011474,
  "AckDt": "2020-08-05 22:55:00",
  "Status": "ACT",
  "DocDtls": { "Typ": "INV", "No": "CTDN23456", "Dt": "05/08/2020" }
}

The program reads them, writes both date fields in standard forms, then shows the two slips:

AckDt and Dt written as RFC 3339 and as an ISO date Python · india/normalise_gst.py
"""Two date fields of an e-invoice acknowledgement, AckDt and the invoice's Dt, written as RFC 3339 on purpose."""
import json
from datetime import datetime, timedelta, timezone

IST = timezone(timedelta(hours=5, minutes=30))  # India keeps one offset all year, so a fixed offset is exact

with open("ack.json", encoding="utf-8") as f:
    ack = json.load(f)

# AckDt has a space instead of T and no offset. The portal's clock is in India, so the zone is attached by us.
acked = datetime.strptime(ack["AckDt"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=IST)
print("AckDt as RFC 3339:", acked.isoformat())
print("AckDt in UTC:     ", acked.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"))

# The invoice date is day first. Say so in the format; never let a parser guess.
invoice_day = datetime.strptime(ack["DocDtls"]["Dt"], "%d/%m/%Y").date()
print("Dt as an ISO date:", invoice_day.isoformat())

# The two slips this avoids, run on a computer whose clock is set to UTC:
naive = datetime.fromisoformat(ack["AckDt"])
print("Slip 1, zone left to the computer:", naive.astimezone().isoformat())
print("Slip 2, month first:", datetime.strptime(ack["DocDtls"]["Dt"], "%m/%d/%Y").date().isoformat())
for text in ["25/08/2020", "05/08/2020"]:
    try:
        datetime.strptime(text, "%m/%d/%Y")
        print(f"  month first accepts {text} without a word")
    except ValueError:
        print(f"  month first refuses {text}: only days above 12 give the slip away")

Output

AckDt as RFC 3339: 2020-08-05T22:55:00+05:30
AckDt in UTC:      2020-08-05T17:25:00Z
Dt as an ISO date: 2020-08-05
Slip 1, zone left to the computer: 2020-08-05T22:55:00+00:00
Slip 2, month first: 2020-05-08
  month first refuses 25/08/2020: only days above 12 give the slip away
  month first accepts 05/08/2020 without a word

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 normalise_gst.py

AckDt becomes 2020-08-05T22:55:00+05:30, which is 2020-08-05T17:25:00Z, and Dt becomes 2020-08-05. Left to the computer, the zone came out as UTC, five and a half hours late; read month first, the invoice date became the eighth of May. The month-first reading refused 25/08/2020, because no month 25 exists, but accepted 05/08/2020 without a word: tests with days up to 12 pass and production data breaks. JavaScript has no format strings for parsing, so its version names every part in a pattern and checks that the day exists, because Date.UTC would roll 31/02 into March:

The same two fields in JavaScript JavaScript · india/normalise_gst.mjs
// The same two fields in JavaScript, which has no strptime: each form is matched by a pattern that names its parts.
import { readFileSync } from "node:fs";

const ack = JSON.parse(readFileSync("ack.json", "utf8"));
const IST_MINUTES = 330; // +05:30

// "YYYY-MM-DD HH:MM:SS", a wall-clock reading in India.
function istToUtc(text) {
  const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(text);
  if (!m) throw new Error(`not YYYY-MM-DD HH:MM:SS: ${text}`);
  const [y, mo, d, h, mi, s] = m.slice(1).map(Number);
  return new Date(Date.UTC(y, mo - 1, d, h, mi, s) - IST_MINUTES * 60_000);
}

// "DD/MM/YYYY": refused unless the day really exists (Date.UTC would roll 31/02 into March).
function dayFirst(text) {
  const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(text);
  if (!m) throw new Error(`not DD/MM/YYYY: ${text}`);
  const [d, mo, y] = m.slice(1).map(Number);
  const date = new Date(Date.UTC(y, mo - 1, d));
  if (date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) throw new Error(`no such day: ${text}`);
  return date.toISOString().slice(0, 10);
}

const acked = istToUtc(ack.AckDt);
console.log("AckDt in UTC:     ", acked.toISOString().replace(".000Z", "Z"));
console.log("AckDt as RFC 3339:", new Date(acked.getTime() + IST_MINUTES * 60_000).toISOString().slice(0, 19) + "+05:30");
console.log("Dt as an ISO date:", dayFirst(ack.DocDtls.Dt));

// The two slips, on a computer whose clock is set to UTC.
console.log("Slip 1, zone left to the computer:", new Date(ack.AckDt).toISOString());
console.log("Slip 2, month first:", new Date(ack.DocDtls.Dt).toISOString().slice(0, 10));
for (const text of ["31/02/2020", "5/8/2020"]) {
  try {
    dayFirst(text);
  } catch (e) {
    console.log("  refused:", e.message);
  }
}

Output

AckDt in UTC:      2020-08-05T17:25:00Z
AckDt as RFC 3339: 2020-08-05T22:55:00+05:30
Dt as an ISO date: 2020-08-05
Slip 1, zone left to the computer: 2020-08-05T22:55:00.000Z
Slip 2, month first: 2020-05-08
  refused: no such day: 31/02/2020
  refused: not DD/MM/YYYY: 5/8/2020

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node normalise_gst.mjs

Testing a date parser with small days

Wrong: a test file whose dates all fall on days 1 to 12, so a month-first parser passes every test. Right: include a day above 12 in every date test, such as 25/08/2020, and a day that does not exist, such as 31/02/2020, which must be refused.

Date Format Converter (DD/MM/YYYY ↔ MM/DD/YYYY ↔ ISO 8601) Convert a list of dates between DD/MM/YYYY, MM/DD/YYYY and ISO 8601, with the ambiguous ones flagged.

Checkpoint

Check: Day-first dates and implied zones

  1. Question 1 of 2 A test suite for a day-first date parser uses only days from 1 to 12. A month-first parser goes in by mistake. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Every test passes, and real data with later days breaks

    normalise_gst.py's month-first reading refused the 25th, because no month 25 exists, and accepted the 5th of the 8th month as the 8th of the 5th without a word.

  2. Question 2 of 2 AckDt reads 22:55:00 with no offset, and the portal is in India. What is that in UTC, and on which day?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 17:25 UTC on the same day

    normalise_gst.py printed the instant both ways, 22:55 at +05:30 and 17:25 in UTC, and JavaScript's version printed the same.

Practise: normalise four kinds of timestamp

The exercise asks for one function that reads four forms of a moment, a timestamp with its own offset, a local reading, a day-first date and Unix time: The number of seconds since midnight UTC at the start of 1970, not counting leap seconds, such as 1791090000. JavaScript's Date counts milliseconds from the same moment, so its numbers are 1,000 times larger., and writes each as RFC 3339 in UTC. Its sample tests include the boundaries, the days that do not exist and the forms that must be refused:

Exercise · Medium · Python, JavaScript

Normalise four kinds of timestamp to UTC

A merchant's export mixes four ways of writing a moment. Write to_utc(text, offset) in Python (toUtc(text, offset) in JavaScript) that returns the moment as an RFC 3339 string in UTC, YYYY-MM-DDTHH:MM:SSZ, and refuses everything else with an error (ValueError in Python, an Error in JavaScript).

The four forms of text:

  • A timestamp with its own offset: YYYY-MM-DDTHH:MM:SS followed by Z or ±HH:MM. The offset in the text wins.
  • A local reading without an offset: the same, with a T or a space between the date and the time. It is a reading of a clock at offset.
  • A day-first date, DD/MM/YYYY: midnight at offset on that day.
  • Unix seconds: one to eleven digits, the seconds since 1970-01-01T00:00:00Z. Eleven digits reach the year 5138, well inside the four-digit years of RFC 3339; a longer number is refused.

offset is Z or ±HH:MM and defaults to +05:30. A day that does not exist, an hour above 23, an offset such as +5:30 and any other form, such as a date without a time, are refused.

The sample tests show each form, the boundaries and what must be refused.

Python · Starter code · stamps.py

from datetime import datetime, timezone


def to_utc(text, offset="+05:30"):
    """The moment text names, as YYYY-MM-DDTHH:MM:SSZ. This first try reads what fromisoformat reads, month first
    for slashes, and takes every reading without an offset as UTC: the tests show where that goes wrong."""
    if text.isdigit():
        moment = datetime.fromtimestamp(int(text), timezone.utc)
    elif "/" in text:
        moment = datetime.strptime(text, "%m/%d/%Y")
    else:
        moment = datetime.fromisoformat(text)
    if moment.tzinfo is None:
        moment = moment.replace(tzinfo=timezone.utc)
    return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
The sample tests · test_stamps.py
from stamps import to_utc


def refused(text, offset="+05:30"):
    try:
        to_utc(text, offset)
    except ValueError:
        return True
    return False


def test_own_offset_wins():
    """a timestamp with its own offset names one moment, whatever offset is passed"""
    assert to_utc("2026-10-04T10:30:00+05:30") == "2026-10-04T05:00:00Z"
    assert to_utc("2026-10-04T05:00:00Z", "-04:00") == "2026-10-04T05:00:00Z"
    assert to_utc("2026-10-03T19:00:00-10:00") == "2026-10-04T05:00:00Z"


def test_local_readings():
    """a reading without an offset is a clock at offset, with T or a space"""
    assert to_utc("2020-08-05 22:55:00") == "2020-08-05T17:25:00Z"
    assert to_utc("2026-10-04T10:30:00", "-04:00") == "2026-10-04T14:30:00Z"
    assert to_utc("2026-12-31 23:59:59", "Z") == "2026-12-31T23:59:59Z"
    assert to_utc("2026-01-01T03:00:00", "+05:30") == "2025-12-31T21:30:00Z"


def test_day_first_dates():
    """DD/MM/YYYY is midnight at offset, day first"""
    assert to_utc("05/08/2020") == "2020-08-04T18:30:00Z"
    assert to_utc("31/12/2025", "Z") == "2025-12-31T00:00:00Z"
    assert to_utc("29/02/2024", "+01:00") == "2024-02-28T23:00:00Z"


def test_unix_seconds():
    """digits are seconds since the epoch, in UTC"""
    assert to_utc("0") == "1970-01-01T00:00:00Z"
    assert to_utc("1791090000", "-04:00") == "2026-10-04T05:00:00Z"


def test_refuses_what_does_not_exist():
    """days, months and hours that do not exist are refused"""
    for text in ["30/02/2026", "29/02/2026", "01/13/2026", "2026-02-29 10:00:00", "2026-04-31T10:00:00Z", "2026-10-04T24:00:00Z"]:
        assert refused(text), text


def test_refuses_other_forms():
    """a date alone, other separators, short offsets and empty text are refused"""
    for text in ["2026-10-04", "04-10-2026", "5/8/2020", "2026-10-04T10:30:00+5:30", "1e9", "999999999999", ""]:
        assert refused(text), text
    assert refused("2026-10-04 10:30:00", "+5:30")
    assert refused("2026-10-04 10:30:00", "IST")
    assert refused("2026-10-04 10:30:00", "+24:00")
    assert refused("2026-10-04T10:30:00+05:60")

JavaScript · Starter code · stamps.mjs

/** The moment text names, as YYYY-MM-DDTHH:MM:SSZ. This first try lets Date guess every form: the tests show where
 * that goes wrong. */
export function toUtc(text, offset = "+05:30") {
  const ms = /^\d+$/.test(text) ? Number(text) * 1000 : Date.parse(text);
  return new Date(ms).toISOString().slice(0, 19) + "Z";
}
The sample tests · stamps.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { toUtc } from './stamps.mjs';

const refused = (text, offset = '+05:30') => {
  try {
    toUtc(text, offset);
  } catch {
    return true;
  }
  return false;
};

test('a timestamp with its own offset names one moment, whatever offset is passed', () => {
  assert.equal(toUtc('2026-10-04T10:30:00+05:30'), '2026-10-04T05:00:00Z');
  assert.equal(toUtc('2026-10-04T05:00:00Z', '-04:00'), '2026-10-04T05:00:00Z');
  assert.equal(toUtc('2026-10-03T19:00:00-10:00'), '2026-10-04T05:00:00Z');
});

test('a reading without an offset is a clock at offset, with T or a space', () => {
  assert.equal(toUtc('2020-08-05 22:55:00'), '2020-08-05T17:25:00Z');
  assert.equal(toUtc('2026-10-04T10:30:00', '-04:00'), '2026-10-04T14:30:00Z');
  assert.equal(toUtc('2026-12-31 23:59:59', 'Z'), '2026-12-31T23:59:59Z');
  assert.equal(toUtc('2026-01-01T03:00:00', '+05:30'), '2025-12-31T21:30:00Z');
});

test('DD/MM/YYYY is midnight at offset, day first', () => {
  assert.equal(toUtc('05/08/2020'), '2020-08-04T18:30:00Z');
  assert.equal(toUtc('31/12/2025', 'Z'), '2025-12-31T00:00:00Z');
  assert.equal(toUtc('29/02/2024', '+01:00'), '2024-02-28T23:00:00Z');
});

test('digits are seconds since the epoch, in UTC', () => {
  assert.equal(toUtc('0'), '1970-01-01T00:00:00Z');
  assert.equal(toUtc('1791090000', '-04:00'), '2026-10-04T05:00:00Z');
});

test('days, months and hours that do not exist are refused', () => {
  for (const text of ['30/02/2026', '29/02/2026', '01/13/2026', '2026-02-29 10:00:00', '2026-04-31T10:00:00Z', '2026-10-04T24:00:00Z']) {
    assert.ok(refused(text), text);
  }
});

test('a date alone, other separators, short offsets and empty text are refused', () => {
  for (const text of ['2026-10-04', '04-10-2026', '5/8/2020', '2026-10-04T10:30:00+5:30', '1e9', '999999999999', '']) assert.ok(refused(text), text);
  assert.ok(refused('2026-10-04 10:30:00', '+5:30'));
  assert.ok(refused('2026-10-04 10:30:00', 'IST'));
  assert.ok(refused('2026-10-04 10:30:00', '+24:00'));
  assert.ok(refused('2026-10-04T10:30:00+05:60'));
});
A hint

Turn every offset into minutes first (+05:30 is 330, -04:00 is −240, Z is 0). A local reading at offset m is the UTC moment m minutes earlier, so subtract the offset; never add it. Let the date library check that the day exists, and match each form with a pattern anchored at both ends, so that 2026-10-04 alone matches none of them.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Timestamp Converter Turn Unix seconds or milliseconds into dates in any time zone, and dates back into timestamps.

Then test the four ideas, from the parts of a timestamp to day-first dates. Each answer explains itself, and the session returns to the idea you miss:

Practice that adapts to you

Practise this lesson

12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 Which part may an RFC 3339 timestamp leave out?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: The fraction of a second

    date-time is full-date, T, partial-time and time-offset, and partial-time ends with an optional fraction. Every other part must be there.

  2. Question 2 of 12 What does Python 3.14 do with this valid RFC 3339 timestamp?

    Read the code, then choose one answer.

    datetime.fromisoformat("2016-12-31T23:59:60Z")
    Show the answer: Question 2 of 12

    Answer: It raises ValueError: second must be in 0..59

    The last row of rfc3339_check.py: RFC 3339 yes, I-JSON yes, Python ValueError. JavaScript's Date refused it too.

  3. Question 3 of 12 An API must refuse every timestamp that is not valid RFC 3339 with an offset. Which check fits?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Match the RFC 3339 grammar and its ranges first, then parse the text

    A parser is lenient by design, so validity needs its own check: the grammar of section 5.6, the ranges of section 5.7, and a refusal for any text without an offset.

  4. Question 4 of 12 What does Python's json module return for a timestamp in a JSON document?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: A str

    read_dates.py printed str for all three JSON values, and for the CSV and XML values as well.

  5. Question 5 of 12 tomllib loads this file. Which statement is true?

    Read the code, then choose one answer.

    at = 2026-10-04T10:30:00+05:30
    local = 2026-10-04T10:30:00
    Show the answer: Question 5 of 12

    Answer: at is an aware datetime and local is a naive one

    read_dates.py printed datetime with tzinfo=UTC+05:30 for at and datetime with tzinfo=None for local.

  6. Question 6 of 12 One service reads a configuration file with a parser of the YAML 1.2 core schema, another with Psych. A plain value holds a date with a time and no offset. What does each service get?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: A string in the first, and a Time read as UTC in the second once Time is permitted

    The same file means two things to two services. Quote such values, or move the file to a format whose dates mean one thing, such as TOML with offsets.

  7. Question 7 of 12 Which of these tells you how a place's offset changes during the year?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: An IANA time zone such as Europe/London

    attach_zone.py showed it: with Europe/London, a July time took +01:00; with a fixed winter offset it came out an hour late.

  8. Question 8 of 12 That night London's clocks went back from 02:00 to 01:00. What does this print?

    Read the code, then choose one answer.

    london = ZoneInfo("Europe/London")
    t = datetime.fromisoformat("2025-10-26T01:30:00").replace(tzinfo=london, fold=1)
    print(t.astimezone(timezone.utc).strftime("%H:%M"))
    Show the answer: Question 8 of 12

    Answer: 01:30

    attach_zone.py printed 00:30 UTC for fold=0 and 01:30 UTC for fold=1: one wall-clock reading, two instants an hour apart.

  9. Question 9 of 12 A payment happened at 10:30 in Pune, and an audit next year must show its exact instant. Which form fits?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: RFC 3339 with its offset, or the same instant in UTC

    For moments that have happened, the instant is the fact to keep. A zone name is for local times booked in the future, whose instant depends on rules not yet known.

  10. Question 10 of 12 Which Python format string reads a day-first date such as the invoice's Dt?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: %d/%m/%Y

    strptime follows the format exactly and refuses anything else, which is what a day-first reader needs.

  11. Question 11 of 12 On a computer set to UTC, what does V8 return for this date, written day first in India?

    Read the code, then choose one answer.

    new Date("04/10/2026").toISOString()
    Show the answer: Question 11 of 12

    Answer: The tenth day of the fourth month, at midnight UTC

    MDN calls such forms implementation-defined. Parse day-first text with a pattern that names each part, as normalise_gst.mjs does.

  12. Question 12 of 12 One import receives dates from Indian and American partners in the same column, all written with slashes. What do you do?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Record each partner's format in its settings, parse with that format, and refuse values that do not fit

    A day-first and a month-first source cannot share one parser. Keep the format per source, as the date fields of the e-invoice show: each field has one documented form.

Interview questions

Warm-up (fresher to 5 years): how should an API send a timestamp in JSON, and why?

As an RFC 3339 string with seconds and an explicit offset, such as 2026-10-04T10:30:00+05:30 or the same instant in UTC, 2026-10-04T05:00:00Z. JSON has no date type, so the string is the whole contract, and RFC 3339 is the profile of ISO 8601 that the IETF and JSON Schema’s date-time format use. The offset is what matters most: without it the text is only the reading of a clock, and each reader attaches its own zone, so the same text becomes different instants on different computers. I-JSON, RFC 7493, adds upper-case letters and seconds even when they are zero. For a local time booked for the future, send the local time and the zone’s name as well, because the zone’s offset may change before then.

Two design questions follow, about a feed whose times have no offset and about appointments in several countries. Make your own first decision before you read the first step of each:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A partner's JSON feed sends order times without an offset. Your daily totals look right, but orders placed late in the evening sometimes land on the next day. What happened, and how do you fix the feed and the rows already stored?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Senior to staff

    Design how a booking service stores and returns appointment times for clinics in India, the United Kingdom and the United States, including bookings made months ahead. What do you store, and what does the API return?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does an RFC 3339 timestamp contain?

    A full date, the letter T, a time with seconds, and an offset: Z or a sign with hours and minutes. I-JSON adds upper-case letters and seconds even when they are zero.

  2. What does RFC 9557 add to an RFC 3339 timestamp?

    A time zone name in brackets after the offset, such as [Asia/Kolkata]; a ! inside the bracket makes it critical, so a reader that cannot use it must refuse.

  3. Which formats give back typed dates?

    Only TOML always does, and Psych does for YAML 1.1 timestamps when allowed. JSON, CSV, XML without a schema and YAML's 1.2 core schema give back text.

  4. How does Psych read a YAML timestamp that has no offset?

    As UTC.

  5. Why is a time without an offset dangerous in data?

    It is only a clock reading: each reader attaches its own zone, so one text becomes different instants on different computers.

  6. How should a future appointment be stored?

    As its local time with the zone's name, because the zone's offset may change before the day; attach the zone with zoneinfo for places whose offset changes.

  7. Why do tests of day-first date parsing need days above 12?

    Only those dates fail under a month-first reading. A date whose day is 12 or less parses either way, wrongly but without an error.

Key takeaways

  • An RFC 3339 timestamp is a full date, T, a time with seconds and an offset; I-JSON asks for upper case and seconds, RFC 9557 adds a zone name in brackets, and a parser that reads a string is not proof that the string is valid.
  • JSON, CSV, XML without a schema and YAML’s 1.2 core schema return dates as text; TOML types them, and Psych reads YAML 1.1 timestamps, taking a time without an offset as UTC.
  • A time without an offset is a clock reading that each reader turns into a different instant; attach the zone on purpose, with zoneinfo for places whose offset changes, and send future local times with the zone’s name.
  • Day-first dates such as 05/08/2020 need a parser that is told the order and checks that the day exists, and tests need days above 12, because a month-first reading fails only for them.

Terms in this lesson

fold
The attribute of a Python datetime that chooses between the two moments a local time names when clocks go back: 0 for the first, 1 for the second, as PEP 495 defines it.
IANA time zone
A named region such as Asia/Kolkata or Europe/London, whose history of UTC offsets and daylight-saving changes the IANA Time Zone Database records. Python's zoneinfo and JavaScript's Intl read it.
leap second
A second added to UTC as second 60 of the last minute of a month, to keep UTC close to the Earth's rotation. RFC 3339 allows it; Python's datetime and JavaScript's Date both refuse it.
RFC 3339
The IETF's profile of ISO 8601 for timestamps on the internet: a full date, the letter T, a time with seconds, an optional fraction and an offset, Z or a sign with hours and minutes. JSON Schema's date-time format refers to its grammar.
Unix time
The number of seconds since midnight UTC at the start of 1970, not counting leap seconds, such as 1791090000. JavaScript's Date counts milliseconds from the same moment, so its numbers are 1,000 times larger.
UTC offset
How far a local clock is ahead of or behind UTC, written Z or as a sign with hours and minutes, such as +05:30 for India. An offset fixes one instant but says nothing about the rules of the place.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.