← Front-End Developer — all chapters  ·  Kelick Tech Hub

Chapter 19: Dates, Times and JSON

Section 1

Dates and times are among the most common kinds of data a program handles, and also among the easiest to get wrong. A program records when something happened, schedules something for a future moment, calculates how much time has passed, or displays a date to a user in a readable form. Each of those tasks looks simple, but each hides complications: months have different lengths, years sometimes have an extra day, clocks change with time zones and daylight saving, and the same moment can be written in many different formats. Python's datetime module provides types that handle these complications systematically, so a program does not have to reinvent calendar arithmetic.

The module's three core types are date, time, and datetime. A date holds a calendar day — a year, a month, and a day — with no time of day attached. A time holds a time of day — hours, minutes, seconds, and optionally microseconds — with no calendar day attached. A datetime combines both: a calendar day plus a time of day, which together identify a specific moment, at least within a given time zone. Choosing the right type matters, because using a datetime where a date would do invites confusion about what the time part means, while using a date where a moment is needed loses information.

All three types are immutable: once created, their values cannot be changed. Operations that appear to modify a date or time, such as adding a duration or replacing a field, actually return a new object, leaving the original untouched. Immutability makes date values safe to share between parts of a program, since no part can accidentally change a value another part relies on. It also makes reasoning about date code easier, because a value you hold cannot change behind your back.

A further distinction runs through the whole module: naive versus aware values. A naive date or time carries no time zone information at all — it is just a calendar reading, like the time shown on a wall clock, with no statement about where that clock hangs. An aware value carries time zone information, so it identifies a moment unambiguously. Much of the confusion in date programming comes from mixing naive and aware values, or from treating a naive value as if it identified a moment when it only identifies a reading. This chapter introduces that distinction early and returns to it often.

The practical advice for now is to start simply: use date for calendar days, datetime for moments, and time only when a time of day genuinely stands alone, such as an opening hour. Keep naive and aware values separate in your mind, and prefer aware datetimes whenever a value represents a real moment that might be compared or shared across time zones.

from datetime import date, time, datetime
print(date(2026, 10, 30))
print(time(9, 30))
print(datetime(2026, 10, 30, 9, 30))

Check: When should you use a date, a time, and a datetime, and what is the difference between a naive and an aware value?

Section 2

Creating date and time objects is straightforward once you know the argument order. A date takes year, month, and day, in that order: date(2026, 10, 30) is the 30th of October 2026. A time takes hour, minute, and second, with optional microseconds: time(9, 30) is half past nine in the morning. A datetime takes both groups together: year, month, day, hour, minute, and second. The order follows the natural way dates are written from largest unit to smallest, which makes it easier to remember than a format that starts with the day.

Invalid values are rejected immediately. date(2026, 2, 30) raises a ValueError, because February never has thirty days, and time(25, 0) raises a ValueError because hours run from 0 to 23. That immediate rejection is a benefit: a program that constructs a date from user input learns at once that the input is invalid, rather than carrying a nonsensical date forward into calculations that would produce nonsense results. Validating at construction is one of the module's quiet strengths.

For the current day or moment, the module provides class methods rather than requiring you to look up the calendar yourself. date.today() returns today's date according to the local clock, and datetime.now() returns the current local date and time. There is also datetime.now(timezone.utc) for the current moment in UTC, which is the time zone reference used throughout this chapter and which later sections explain in detail. Using these methods keeps a program's "now" consistent, since every call reads the same system clock.

Accessing the parts of a date or datetime is done through attributes: .year, .month, .day on a date, and additionally .hour, .minute, .second on a datetime. These attributes are read-only, consistent with immutability, and they let a program extract exactly the part it needs — the year for a report heading, the month for grouping records — without parsing a string. That is another reason to keep dates as date objects for as long as possible, converting to strings only when displaying them.

A useful companion method is replace(), which returns a new object with one or more fields changed: d.replace(year=2027) gives the same month and day in the following year. Like all operations on these types, it returns a new object rather than modifying the original, and it validates the result, so replacing the year of a leap-day date with a non-leap year raises an error rather than silently producing an invalid date.

from datetime import date
d = date(2026, 10, 30)
print(d.year, d.month, d.day)
print(d.replace(year=2027))

When a date comes from user input, validate it at the boundary by constructing the date object immediately, so invalid input is rejected with a clear message before it reaches the rest of the program.

Check: Why is it helpful that invalid dates raise an error at construction, and how does replace() differ from modifying an object in place?

Section 3

The words "today" and "now" sound simple, but in a program they raise a question: according to whose clock, and in which time zone? date.today() and datetime.now() answer according to the local clock of the machine running the program, which is convenient for displaying a date to a user sitting at that machine, but unreliable for anything that must be consistent across machines. Two machines in different time zones will report different "now" values at the same instant, and a program that records timestamps with local time will produce records that are hard to compare.

The remedy is to anchor timestamps to a single reference, and the standard reference is UTC — Coordinated Universal Time, the time standard that all time zones are defined relative to. datetime.now(timezone.utc) returns the current moment as an aware datetime in UTC, and that value means the same thing on every machine, regardless of its local settings. A program that stores UTC timestamps can always convert them to any local time for display, while a program that stores local timestamps has lost information it cannot recover.

That last point deserves emphasis. A local timestamp without time zone information is ambiguous: the same local reading can occur twice on a day when clocks go back, and a local reading from a machine whose time zone setting changed cannot be reinterpreted correctly afterwards. Storing the time zone alongside a local time helps, but storing UTC and converting for display is simpler and less error-prone. The rule of thumb is: store and compare in UTC, display in local time.

For dates without times, the same logic applies more gently. A "today" recorded as a date depends on the local calendar day, which differs across time zones near midnight. A program that records the date of an event should decide whose "today" it means — usually the user's local day — and document that choice, rather than assuming that date.today() is universal. Dates are local by nature in a way that moments are not, and that difference is worth noticing early.

In practice, a small program can start with local times and grow into UTC timestamps as its needs grow. The important step is to make the choice deliberately, to name it in a comment, and to keep naive and aware values from mixing, which the module prevents by raising an error when you try to compare them.

from datetime import datetime, timezone
print(datetime.now(timezone.utc))
print(datetime.now())

A useful test for timestamp code is to run the same program with different system time zones and check that stored values do not change, since a program that stores local time will fail that test. Document the chosen reference in a comment near the code that records timestamps.

Check: Why should timestamps be stored in UTC rather than local time, and what information is lost when a local time is stored without its time zone?

Section 4

Durations are represented by the timedelta type, which is a span of time rather than a point in time. A timedelta is built from keyword arguments such as days, hours, minutes, seconds, and weeks: timedelta(days=3, hours=2) is three days and two hours. Adding a timedelta to a date or datetime moves it forward by that span, and subtracting moves it backward, with the calendar arithmetic handled correctly — adding one day to the last day of a month lands on the first day of the next month, without the program needing to know how many days the month has.

Subtracting two dates or datetimes gives a timedelta representing the span between them, which is how a program answers "how many days until the deadline?" or "how long did this take?". The resulting timedelta exposes its size through .days, .seconds, and .total_seconds(). Note that .days and .seconds are components, not totals: a span of 25 hours has .days equal to 1 and .seconds equal to 3600, while .total_seconds() gives 90000, the full span in seconds. Confusing a component with a total is a common source of small, hard-to-spot errors in duration code.

Timedeltas have limits that are worth knowing. There is no months or years argument, because months and years do not have fixed lengths: adding "one month" to the 31st of January is ambiguous, since February has no 31st. A program that needs month arithmetic must define its own rule — for example, clamping to the last day of the target month — and implement it explicitly, rather than expecting the standard library to guess. That explicitness is a feature: it forces the programmer to decide what "one month later" means for their program.

Timedeltas also support comparison and arithmetic with each other: spans can be added, subtracted, multiplied by a number, and compared, which makes it easy to express rules like "a task is overdue if the time since its deadline exceeds seven days". Combined with comparison of dates themselves, that gives a program a complete vocabulary for time-based rules, expressed in readable operations rather than manual arithmetic on years, months, and days.

from datetime import date, timedelta
d = date(2026, 10, 30)
print(d + timedelta(days=3))
print((d + timedelta(days=3) - d).days)

Timedeltas also appear in timeouts and delays: a function that waits for a given duration, or a cache that expires entries after a given span, expresses that span naturally as a timedelta rather than as a raw number of seconds. A timedelta of zero is a valid duration, and comparing a span with zero is a clear way to test whether a deadline has passed. Weeks are a convenience: timedelta(weeks=1) is exactly seven days, nothing more.

Check: Why does timedelta have no months argument, and why is .total_seconds() different from .seconds?

Section 5

Dates and datetimes can be compared with the usual operators — <, <=, ==, >, >= — and comparison follows chronological order, which is the natural order for time. That makes sorting records by date as simple as sorting by any other value, and makes conditions like "is this deadline in the past?" read directly as code: deadline < today. Sorting a list of events by their datetimes produces chronological order without any special handling, and finding the earliest or latest value is a call to min() or max().

Equality has a subtlety worth knowing. Two datetimes that represent the same moment but carry different time zone information compare equal if they are both aware, because comparison converts to a common reference. Two naive datetimes compare by their readings, with no conversion. But a naive datetime and an aware datetime cannot be compared at all: attempting it raises a TypeError. That error is deliberate protection against a meaningless comparison, since a reading without a time zone and a moment with one are not comparable quantities. A program that hits this error has mixed two kinds of value that should have been kept apart.

The same protection applies to arithmetic: adding a timedelta to either kind works, but subtracting an aware datetime from a naive one raises the same TypeError. The remedy is always the same — decide which kind a value should be, convert at the boundary where data enters the program, and keep the kinds separate afterwards. Converting a naive value to aware requires knowing its intended time zone; it cannot be guessed from the value itself.

Comparison also underpins common patterns such as checking whether a moment falls within a range. A moment is within a range when it is not before the start and not after the end, which in code is a chained comparison: start <= moment <= end. Chained comparisons read naturally and avoid the mistake of comparing against only one end of a range. For dates, the same pattern checks whether a day falls within a period, such as a booking window or a reporting month.

Finally, remember that comparing dates of different types — a date with a datetime — compares only in limited ways: equality between a date and a datetime is always false, since they are different types, and ordering comparisons raise TypeError. Keep like with like, and convert explicitly when a comparison genuinely needs both.

from datetime import date
print(date(2026, 10, 30) < date(2026, 11, 1))
print(min([date(2026, 12, 1), date(2026, 1, 1)]))

Sorting datetimes is stable and predictable, so a list of events sorted by datetime can be displayed chronologically, and equal moments keep their original relative order in the sorted output.

Check: Why does comparing a naive datetime with an aware datetime raise an error, and why is that error helpful rather than annoying?

Section 6

Displaying a date means converting it to a string, and the strftime() method does that conversion according to a format string of directives. Each directive is a percent sign followed by a letter: %Y for the four-digit year, %m for the two-digit month, %d for the two-digit day, %H for the hour in 24-hour form, and %M and %S for minutes and seconds. Combining them gives familiar formats: d.strftime("%Y-%m-%d") produces 2026-10-30, and d.strftime("%d %B %Y") produces 30 October 2026, using %B for the full month name.

The choice of format depends on the audience. For display to users, a format with month names and no leading zeros often reads best, since people do not naturally say "the 05th of March". For logs and filenames, a compact numeric format is better, because it sorts correctly as text and is unambiguous. The ISO-style format %Y-%m-%d has a special advantage: when such strings are sorted alphabetically, they are also in chronological order, which is why it is the standard choice for filenames, logs, and stored dates.

Month and weekday names produced by strftime() depend on the program's locale settings, which is the system's language and cultural configuration. On a machine configured for another language, %B may produce a month name in that language rather than English. That is correct behaviour for display to a local user, but it means formatted dates should not be used as stored data or as keys, since their text depends on machine settings. Store dates as date objects or in ISO format, and format them for display only at the last moment.

Formatting a time or a datetime follows the same pattern, with hour directives in 12-hour and 24-hour forms: %H for 24-hour, %I for 12-hour together with %p for AM or PM. A common mistake is to use %I without %p, producing an hour that is ambiguous between morning and evening. Another is to use %m for minutes — %m is the month, %M is minutes — a confusion that produces dates with the month where the minutes should be. Checking a formatted result against a known date catches both mistakes quickly.

from datetime import date
d = date(2026, 10, 30)
print(d.strftime("%Y-%m-%d"))
print(d.strftime("%d %B %Y"))

For user-facing dates, consider the reader's conventions: some readers expect day-first order, others month-first, and a format that spells out the month avoids the ambiguity that purely numeric formats create. Numeric formats are best for machines, and month names are best for people; choose according to who reads the output. Always test formatting with single-digit days and months, since padding bugs show up there first. ISO format sorts correctly as text, which is why logs use it. Use month names for people, numbers for machines.

Check: Why is the ISO format %Y-%m-%d a good choice for filenames and logs, and why should formatted dates not be used as stored data?

Section 7

Parsing is the reverse of formatting: converting a string into a date or datetime, and the strptime() class method does it according to a format string of the same directives. datetime.strptime("2026-10-30", "%Y-%m-%d") produces the datetime for that day at midnight, and datetime.strptime("30/10/2026", "%d/%m/%Y") handles the day-first format common in many countries. The format must match the string exactly, including separators, or parsing raises a ValueError.

That strictness is both a strength and a demand. It is a strength because a mismatched format fails loudly rather than silently misreading a date — parsing "10/11/2026" with a month-first format when the writer meant day-first would swap month and day, and a strict parser at least fails when the values are impossible, such as a month of 13. It is a demand because a program that accepts dates from users must know, or discover, which format those users write. Accepting several formats means trying each in turn and handling the failures, which is straightforward but must be written deliberately.

A special case is the ISO format, which datetime.fromisoformat() parses without a format string. That method handles the standard YYYY-MM-DD date form and the extended datetime forms, and it is the natural companion to the isoformat() method covered later in this chapter. When a program controls both the writing and the reading of a date string, using ISO format on both sides removes the need to agree on a custom format at all, and removes an entire class of parsing mistakes.

Parsing user input also raises a design question: should a program accept many formats, or insist on one? Insisting on one format, clearly shown in a prompt or placeholder, is simpler to implement and to explain, and it avoids the ambiguity of dates like 01/02/2026, which could be the 1st of February or the 2nd of January depending on convention. Accepting many formats is friendlier but riskier, and the friendly choice should still reject ambiguous inputs rather than guess. A good rule is to accept one format well, and to show it to the user.

from datetime import datetime
print(datetime.strptime("2026-10-30", "%Y-%m-%d"))
print(datetime.fromisoformat("2026-10-30"))

When a program must accept dates in several formats, try the most specific format first and the most general last, and log which format matched, so ambiguous inputs can be investigated rather than silently accepted. Show the expected format in prompts and error messages, so users can correct their input without guessing what the program wants. Rejecting an ambiguous date is kinder than guessing wrong, since a wrong date can cause a missed deadline. A clear error message naming the expected format helps users fix input quickly. One accepted format, clearly shown, beats many guessed formats. Ambiguity rejected is better than ambiguity guessed.

Check: Why is strict format matching in strptime() a strength, and why should ambiguous date strings be rejected rather than guessed?

Section 8

The three types — date, time, and datetime — overlap in ways that can confuse, so it is worth stating their relationships plainly. A datetime is a subclass of date, which means that every datetime is also a date in the type hierarchy, and that a datetime can be used wherever a date is expected in code that only reads the calendar parts. The reverse is not true: a plain date is not a datetime, and code that needs a time of day cannot use a date. That subclass relationship is a convenience, but it also explains why comparing a date with a datetime for equality is always false even when they share a calendar day.

Converting between the types is explicit. A datetime's .date() method returns its calendar day as a plain date, discarding the time part, and its .time() method returns its time of day as a time, discarding the date part. Combining a date and a time back into a datetime is done with datetime.combine(). These conversions are the bridges between the types, and using them explicitly keeps a program's intent visible: when you call .date(), you are stating that the time part no longer matters for what follows.

A time object on its own is the least commonly needed of the three. It suits values that genuinely repeat daily, such as an opening hour, a daily alarm time, or a schedule entry that applies to every day. For a specific occurrence — "the meeting on the 30th at half past nine" — a datetime is correct, because the day matters as much as the hour. Using a time for a specific occurrence loses the day and invites errors when the occurrence is compared or sorted with others.

One more distinction concerns midnight. A datetime at midnight on a given day, such as datetime(2026, 10, 30, 0, 0), is a moment at the start of that day, not a representation of the day itself. Treating midnight datetimes as if they were dates works in many cases, since their calendar parts match, but it breaks when time zones are involved, because midnight in one zone is a different moment from midnight in another. When a value means a day, use a date; when it means a moment, use a datetime, and do not let midnight stand in for a day.

from datetime import datetime, date, time
dt = datetime(2026, 10, 30, 9, 30)
print(dt.date(), dt.time())
print(datetime.combine(date(2026, 10, 30), time(9, 30)))

A date object also supports weekday calculations and calendar operations that a datetime at midnight supports too, but the date form states the intent more clearly to anyone reading the code. Prefer date when the value is a day, and datetime when the value is a moment.

Check: Why is a datetime at midnight not a safe substitute for a date, and how do you convert between date, time, and datetime?

Section 9

Time zones are offsets from UTC, together with rules for when those offsets change. The timezone class in the datetime module provides fixed-offset time zones: timezone.utc is UTC itself, and timezone(timedelta(hours=1)) is a zone one hour ahead of UTC. Fixed offsets are sufficient for UTC itself and for zones without daylight saving changes, and they are the simplest aware values to create. Attaching one to a datetime, as in datetime(2026, 10, 30, 9, 30, tzinfo=timezone.utc), makes that datetime aware.

Real-world time zones, however, are not fixed offsets. Most zones change their offset during the year for daylight saving, and their rules have changed historically. The zoneinfo module provides real time zone objects identified by names such as Europe/London or Africa/Lagos, which encode those changing rules. A datetime with a ZoneInfo time zone knows its correct offset for its own date, including whether daylight saving applies on that date, which a fixed offset cannot know. For a program that works with real places and real dates, named zones are the correct tool.

Creating an aware datetime with a named zone is done the same way as with a fixed offset: pass the zone as tzinfo. The difference appears when the datetime is converted or when its offset is inspected: a ZoneInfo datetime reports the offset that actually applied on its date, while a fixed-offset datetime reports the same offset for every date. That difference is invisible for dates where the two agree, and decisive for dates where they do not, which is why the choice matters even when a program's test dates happen to fall in a period where both give the same answer.

A common beginner mistake is to attach a time zone to a naive datetime by simply setting tzinfo, when the naive value was recorded in a different zone. Setting tzinfo does not convert a value; it labels it. If a naive datetime represents a local reading in Lagos, labelling it with UTC does not make it a UTC moment — it makes it a wrongly labelled moment. The correct conversion, covered in the next section, uses astimezone() on an already-aware value, or constructs the aware value in its true zone from the start.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
dt = datetime(2026, 10, 30, 9, 30, tzinfo=ZoneInfo("Africa/Lagos"))
print(dt, dt.utcoffset())
print(datetime(2026, 10, 30, 9, 30, tzinfo=timezone.utc))

For a program that only ever deals with UTC, fixed offsets are enough, and named zones add complexity without benefit; add named zones when the program first needs to display local times for real places. Named zones also handle historical rule changes, which fixed offsets cannot represent at all. A fixed offset is a single number; a named zone is a set of rules over time.

Check: Why is a named time zone more correct than a fixed offset for real places, and why does setting tzinfo not convert a naive datetime?

Section 10

Converting between time zones is done with astimezone(), which returns a new datetime representing the same moment in a different zone. Given an aware datetime in UTC, dt.astimezone(ZoneInfo("Africa/Lagos")) returns the corresponding local reading in Lagos, with the same instant but different clock fields. That is the operation behind every "show this moment in the user's local time" feature, and it is safe because both values are aware: the module knows exactly what moment is meant, and can compute the correct local reading, including any daylight saving adjustment for that date.

The reverse direction — converting a local reading to UTC — works the same way, provided the local reading is already aware in its true zone. A datetime constructed with ZoneInfo("Africa/Lagos") can be converted to UTC with astimezone(timezone.utc), and the result is the same moment expressed in UTC. The key requirement is that the starting value is aware in the zone it truly belongs to; astimezone() on a naive value assumes the system's local zone, which is a guess that may be wrong, and should not be relied on for data whose zone is known.

A subtle case arises around daylight saving transitions, where a local reading can be ambiguous — occurring twice when clocks go back — or non-existent — skipped when clocks go forward. The fold attribute on a datetime distinguishes the two occurrences of an ambiguous reading, with fold=0 for the first and fold=1 for the second. Most programs never need to set fold explicitly, because most local readings are unambiguous, but knowing that it exists explains why a program that constructs datetimes around a transition can produce surprising results, and why testing with transition dates is worthwhile for time-sensitive code.

The practical pattern for a program that serves users in several zones is now complete: store moments in UTC, convert to the user's zone for display with astimezone(), and accept user input in the user's zone, constructing an aware datetime in that zone before converting to UTC for storage. Each step is explicit, each value's zone is known, and no step relies on guessing a zone from a bare reading.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
utc = datetime(2026, 10, 30, 8, 30, tzinfo=timezone.utc)
print(utc.astimezone(ZoneInfo("Africa/Lagos")))

When displaying a converted time, include the time zone name or abbreviation alongside the reading, so the reader knows which zone the displayed time belongs to and is not misled into treating it as UTC. Conversion is exact for aware values, because the moment is known and the zone rules supply the offset. Store in UTC, display in local time, and convert explicitly at both boundaries. Test conversions with dates in both halves of the year, since daylight saving differs between them.

Check: What does astimezone() preserve when it changes a datetime's zone, and why must the starting value already be aware in its true zone?

Section 11

A timestamp is a number that counts seconds from a fixed starting point, and it is the most compact way to represent a moment. The starting point, called the epoch, is midnight at the start of the 1st of January 1970 in UTC, and a timestamp is the number of seconds elapsed since then. The current moment's timestamp is a large number, around 1.7 billion at the time of writing in general terms, and it increases by one every second. Timestamps are used in file metadata, in logs, and in systems that exchange moments as numbers rather than as formatted text.

Python provides two directions of conversion. An aware datetime's .timestamp() method returns its timestamp as a float, including fractional seconds for the microseconds part. The reverse, datetime.fromtimestamp(ts, tz=timezone.utc), returns the aware datetime for a given timestamp in the requested zone. Both conversions are exact for ordinary moments, and both require care about time zones: calling fromtimestamp() without a zone returns a naive datetime in local time, which reintroduces the ambiguity this chapter warns against. Always pass a zone explicitly, and prefer UTC.

Timestamps have advantages and disadvantages compared with datetime objects. Their advantage is simplicity: a single number is easy to store, to compare, and to pass between systems written in different languages, since the epoch convention is widely shared. Their disadvantage is readability: a timestamp means nothing to a human reader, so debugging a program that logs only timestamps is painful, and a timestamp carries no information about which calendar system or time zone produced it — that knowledge lives in the convention, not in the number.

Another disadvantage is precision and range. A timestamp as a float loses precision for very distant dates, because floating-point numbers cannot represent every integer exactly at large magnitudes, though for dates near the present the precision is far finer than a microsecond. A timestamp as an integer counts whole seconds, which is enough for many purposes but loses sub-second detail. A program that needs exact microseconds should keep its datetimes as datetime objects and use timestamps only at boundaries where a number is required.

The practical guidance is to use datetimes inside a program, where readability and calendar operations matter, and timestamps at boundaries — file metadata, system calls, and exchange with other systems — where a number is the expected form. Convert at those boundaries, with an explicit time zone, and the program gets the benefits of both representations without their drawbacks.

from datetime import datetime, timezone
dt = datetime(2026, 10, 30, 9, 30, tzinfo=timezone.utc)
ts = dt.timestamp()
print(ts, datetime.fromtimestamp(ts, tz=timezone.utc))

Timestamps are also used in file systems, where a file's modification time is stored as a number, and converting that number with an explicit zone gives the correct local reading.

Check: Why should fromtimestamp() always be given an explicit time zone, and when is a timestamp a better representation than a datetime?

Section 12

The ISO 8601 standard defines text formats for dates and times that are unambiguous and widely understood, and Python's isoformat() method produces them directly from date and datetime objects. A date's isoformat() gives 2026-10-30; a datetime's gives 2026-10-30T09:30:00, with a T separating the date from the time; and an aware datetime's includes its UTC offset, as in 2026-10-30T09:30:00+01:00. That offset suffix is what makes an aware ISO string self-describing: a reader can tell which moment it means without knowing anything else about the program that wrote it.

The companion method fromisoformat() parses these strings back into objects, so a program that writes ISO strings and reads them with fromisoformat() has a complete round trip with no custom format to agree on. That round trip is the reason ISO format is the default recommendation for storing dates as text, whether in a file, in a database text column, or in a JSON document, as later sections of this chapter show. When both writer and reader use ISO format, neither needs to know the other's formatting choices.

ISO format also has variants worth recognising. A datetime string may use a space instead of T as the separator, may omit seconds when they are zero, and may end with Z to mean UTC, a convention that fromisoformat() accepts in current Python 3. A program that accepts ISO strings from other systems should be prepared for these variants, and should test with examples of each, rather than assuming that every producer writes exactly the form its own isoformat() produces.

One caution is that ISO strings sort correctly only when they share the same form: dates in YYYY-MM-DD order sort chronologically, and datetimes with the same offset sort chronologically, but mixing offsets or mixing date-only and datetime strings breaks that property. For sorting stored strings, normalising to a single form — UTC datetimes with offsets, for example — preserves the sorting benefit. For sorting objects, of course, no such concern arises, since datetime comparison handles offsets correctly.

ISO format is a standard, not a Python invention, and that is its strength: a date written in ISO format can be read by programs in any language, by spreadsheets, and by databases, without a Python-specific parser. Choosing a standard format over a custom one is almost always the right decision for data that leaves a program.

from datetime import datetime, timezone
dt = datetime(2026, 10, 30, 9, 30, tzinfo=timezone.utc)
s = dt.isoformat()
print(s, datetime.fromisoformat(s))

When exchanging ISO strings with other systems, test with their actual output, since producers differ in details like fractional seconds and the separator character. ISO strings with offsets are the safest text form for moments, since they carry their zone with them.

Check: Why is an aware ISO string self-describing while a naive ISO string is not, and why should stored ISO strings share one form?

Section 13

The calendar is full of derived facts — what weekday a date falls on, how many days a month has, whether a year is a leap year — and Python provides them directly rather than requiring manual calculation. A date's .weekday() method returns the weekday as a number from 0 for Monday to 6 for Sunday, while .isoweekday() returns 1 for Monday to 7 for Sunday, matching the ISO convention. The choice between them is a matter of convention, but a program must be consistent, since mixing them shifts every weekday by one in one of the two schemes.

The calendar module extends these basics. Its monthrange() function returns the weekday of the first day of a month and the number of days in that month, which together let a program build a monthly calendar display or find the last day of a month without knowing month lengths by heart. Its isleap() function tests whether a year is a leap year, implementing the full rule — divisible by four, except centuries, except centuries divisible by four hundred — that many hand-written versions get wrong. Using the module's rule avoids the classic bug of treating every year divisible by four as a leap year.

Weekday calculations have practical uses beyond calendars. A program that schedules weekly reports needs to find the next Monday from a given date, which is a small calculation using weekday() and a timedelta. A program that charges only on business days needs to skip Saturdays and Sundays, which is a test on weekday() values. A program that reports by week needs to group dates by their ISO week, which the .isocalendar() method provides as a year, week number, and weekday triple. Each of these is a short piece of code once the weekday primitives are known.

A common mistake is to compute weekdays from a fixed reference date by hand, counting days from a known Monday. That approach works until it meets a leap year or a month boundary handled incorrectly, and it duplicates logic the standard library already provides and tests. The general lesson of this chapter applies here too: calendar arithmetic has more edge cases than it appears to have, and the standard library's calendar functions exist because those edge cases have bitten many programmers before.

import calendar
from datetime import date
d = date(2026, 10, 30)
print(d.strftime("%A"), d.weekday(), calendar.isleap(2026))
print(calendar.monthrange(2026, 10))

Weekday numbers are also used in scheduling rules, where a rule like 'every Friday' is expressed as a weekday test on each candidate date in a range. The ISO week system also defines week numbering, where week 1 is the week containing the first Thursday of the year. Check weekday conventions before coding a schedule.

Check: Why should a program use calendar.isleap() rather than testing divisibility by four itself, and what is the difference between weekday() and isoweekday()?

Section 14

The calendar module is a small library for calendar display and calculation, and it repays a closer look because it shows how the standard library handles a domain with many conventions. Its TextCalendar class produces plain-text monthly calendars, and its HTMLCalendar class produces the same calendars as HTML tables, which a web program can embed directly. Both are driven by the same underlying calculations, so a program that needs a calendar view in two formats does not need two implementations.

The module also lets a program choose which day starts the week. By default, weeks start on Monday, following the ISO convention, but a calendar can be created with Sunday as the first day, following the convention common in some countries. That choice affects the layout of a displayed calendar and the grouping of dates into weeks, so a program that displays calendars to users should let the user's locale or preference decide, rather than hard-coding one convention. The module makes the choice a parameter, not a rewrite.

Beyond display, the module provides functions for calendar arithmetic that complement the datetime module's date arithmetic. monthcalendar() returns a month as a list of weeks, each week as a list of day numbers with zeros for days outside the month, which is a convenient structure for building a calendar grid or for finding, say, the second Tuesday of a month. itermonthdates() iterates over the actual dates in a month's calendar grid, including the days from neighbouring months that fill the first and last weeks, which suits programs that render a full grid.

It is worth noting what the module does not do. It does not handle time zones, it does not schedule events, and it does not know about holidays, which vary by country and by year and belong in a dedicated dataset rather than in a general library. A program that needs holidays must supply its own list, and a program that needs scheduling must build it from dates and datetimes. The module's restraint is a design lesson: a library that tries to cover every calendar-related need would be large and unreliable, while a library that covers the core calculations well is a solid foundation.

import calendar
cal = calendar.TextCalendar()
print(cal.formatmonth(2026, 10))

A calendar grid built from monthcalendar() can be rendered in any format, since the underlying structure of weeks and days is independent of how it is displayed. Calendar display is a presentation concern, and the module keeps it separate from calendar calculation, which is good design. Use TextCalendar for terminal output and HTMLCalendar for web output, sharing the same calculations. Holidays belong in a dataset, not in a general calendar library. Display and calculation are separate concerns in calendar code.

Check: Why does the calendar module let a program choose the first day of the week, and what calendar needs does it deliberately not cover?

Section 15

Adding a duration to a date is easy with timedelta, but adding a calendar period — a month, a quarter, a year — is a different operation with its own rules, and it is worth understanding the difference. A duration is a fixed number of days, hours, or seconds, while a calendar period is defined by the calendar: one month from the 15th of January is the 15th of February, regardless of how many days February has, and one year from the 29th of February is a date that does not exist in a non-leap year. The standard library provides durations, not calendar periods, and a program that needs the latter must define its own rule for the impossible cases.

The most common rule for month arithmetic is clamping: when the target month has fewer days than the starting day, use the last day of the target month instead. One month from the 31st of January, by that rule, is the 28th of February in a non-leap year, or the 29th in a leap year. Another rule is to raise an error for impossible targets, forcing the caller to handle the case explicitly. Which rule is right depends on the program: a billing system probably prefers clamping, so that a monthly charge always has a date, while a scheduling system might prefer an error, so that a human notices the ambiguity.

Implementing month arithmetic is a short exercise that uses the tools already covered: compute the target year and month by arithmetic on the month number, find the target month's length with calendar.monthrange(), and clamp the day to that length. Writing it once, as a helper function, and testing it with edge cases — the 31st of January, the 29th of February, December to January year boundaries — gives a program a reliable calendar operation that the standard library deliberately leaves to the programmer.

Year arithmetic has the same structure with a simpler edge case: adding years changes only the year field, except for the 29th of February, which needs the same clamping or error rule. A helper that adds years can be built with replace(), catching the ValueError for leap-day dates and applying the chosen rule. The lesson of both helpers is general: when a calendar operation has no single correct answer, the program must choose a rule, document it, and apply it consistently, rather than letting different parts of the code choose different rules implicitly.

import calendar
from datetime import date
def add_months(d, n):
    total = d.month - 1 + n
    y, m = d.year + total // 12, total % 12 + 1
    day = min(d.day, calendar.monthrange(y, m)[1])
    return date(y, m, day)
print(add_months(date(2026, 1, 31), 1))

Check: Why does the standard library provide durations but not month arithmetic, and what rule should a program choose for the 31st of January plus one month?

Section 16

The time module, distinct from the time class in datetime, provides lower-level time functions that complement the calendar types. Its time() function returns the current time as a timestamp — seconds since the epoch as a float — which is the numeric form discussed earlier. Its sleep() function pauses a program for a given number of seconds, which is useful for polling, for rate limiting, and for simple delays in scripts. Its monotonic() function returns a clock value that only ever increases, which is the right clock for measuring durations.

The distinction between time() and monotonic() matters for measurement. The wall clock reported by time() can jump forward or backward when the system clock is adjusted, for example by network time synchronisation, so a duration measured with time() can be wrong — even negative — if the clock changes during the measurement. The monotonic clock never goes backward, by design, so a duration measured as the difference of two monotonic readings is always correct, regardless of system clock adjustments. The rule is: wall clock for timestamps, monotonic clock for durations.

The time module also provides localtime() and gmtime(), which convert a timestamp into a struct_time — a named tuple of calendar fields such as year, month, day, hour, and weekday. struct_time is an older representation that predates the datetime types, and modern code generally prefers datetime objects, which are richer and easier to work with. struct_time still appears in older code and in some library interfaces, so recognising it is useful, but new code should use datetime unless an interface specifically requires struct_time.

Another function, strftime() in the time module, formats a struct_time much like the datetime method of the same name, with the same directives. The duplication exists for historical reasons, and it is another reason to prefer datetime in new code: one formatting method, one set of types, and one mental model, rather than two parallel systems. When reading older code that uses the time module heavily, translating its timestamps and struct_time values into datetimes is often the first step to understanding it.

import time
start = time.monotonic()
time.sleep(0.01)
print(time.monotonic() - start)

For measuring elapsed time in a program, prefer monotonic readings taken at the start and end of the measured section, and report the difference, rather than reading the wall clock twice. Sleep durations are approximate, since the operating system may wake a program slightly later than requested. The time module's functions are low-level tools; prefer datetime objects for most application logic. Prefer datetime for application code, and time for low-level needs. Wall clock for timestamps, monotonic clock for durations. Measure durations with monotonic, record moments with wall clock. Monotonic clocks never go backward. Use it.

Check: Why is the monotonic clock correct for measuring durations while the wall clock is not, and when should new code prefer datetime over struct_time?

Section 17

Storing dates is a decision with long consequences, because stored data outlives the program that wrote it. The safest stored form for a moment is an ISO string with its time zone offset, or a UTC timestamp, both of which are self-describing and independent of the machine that reads them. The least safe form is a formatted string in a locale-dependent format, such as a month name in one language, which a reader in another locale may misinterpret or fail to parse. Between those extremes, an ISO date string for a calendar day — 2026-10-30 — is safe, because it carries no time zone ambiguity for a day value.

A related decision is whether to store a date, a datetime, or a timestamp for a given field. Store a date when the value is a day, such as a birth date or a due date, where the time of day is meaningless. Store an aware datetime when the value is a moment, such as when an event occurred or when a record was created. Store a timestamp when the consuming system expects a number, or when storage space is at a premium and the loss of readability is acceptable. Each choice should be documented alongside the field, so that future readers know what the stored value means.

When a stored date is read back, it must be converted to the program's working type before use. An ISO string becomes a date or datetime with fromisoformat(), and a timestamp becomes an aware datetime with fromtimestamp() and an explicit zone. Performing that conversion at the boundary — where data enters the program — and then working with date objects internally, keeps the rest of the program clean: internal code deals with dates and datetimes, not with strings and numbers that happen to represent dates.

Migration is the final consideration. If a program's stored format changes — for example, from local times to UTC timestamps — old records must be converted, and the conversion must know the old format's assumptions, including its time zone. A migration that guesses the old zone will corrupt old records silently. Documenting the stored format from the start, including its time zone, is what makes a later migration possible without guessing.

from datetime import date
stored = "2026-10-30"
d = date.fromisoformat(stored)
print(d, type(d))

When a stored format must change, write a migration that reads old records, converts them, and writes new records, and test it on a copy of real data before running it for real. A stored date's meaning should be written down next to its definition, not left for future readers to guess. Convert stored dates at the boundary, and work with objects inside the program.

Check: Why is a locale-dependent formatted string the least safe stored form for a date, and where in a program should stored dates be converted to date objects?

Section 18

Daylight saving time is the practice of shifting clocks forward in spring and back in autumn, and it is the largest source of complexity in time zone handling. On the day clocks go forward, an hour of local time does not exist: readings in the skipped hour never occur. On the day clocks go back, an hour of local time occurs twice: readings in the repeated hour are ambiguous without additional information. A program that works with local times must be aware of both cases, even if it rarely encounters them, because the errors they cause — a meeting scheduled in a non-existent hour, a log entry with an ambiguous timestamp — are confusing when they do occur.

Named time zones handle these transitions through their rules, and aware datetimes carry the information needed to resolve them. An aware datetime in a named zone knows its offset for its own date, so converting it to UTC gives the correct moment even on a transition day. The ambiguous case is resolved by the fold attribute, introduced earlier: fold=0 selects the first occurrence of a repeated reading, and fold=1 selects the second. A program that constructs local datetimes around a transition should consider fold explicitly, or, better, should construct its datetimes in UTC and convert to local time for display, which avoids constructing ambiguous local readings at all.

The deeper lesson is that local time is a display format, not a storage format. A moment stored in UTC has no ambiguity, no skipped hours, and no repeated hours, because UTC never observes daylight saving. Local time exists for humans, who live by clocks that shift, and converting between the two at the boundaries — storage in UTC, display in local time — confines daylight saving complexity to the display step, where it belongs, rather than letting it infect storage, comparison, and arithmetic.

Not every zone observes daylight saving. Some zones, including several near the equator, keep a fixed offset all year, and their local times have no transitions at all. A program that assumes every zone has transitions will be wrong for those zones, just as a program that assumes no zone has transitions will be wrong for the zones that do. Using named zones, rather than assumptions about any particular zone, is what keeps a program correct for both cases.

from datetime import datetime
from zoneinfo import ZoneInfo
london = ZoneInfo("Europe/London")
print(datetime(2026, 3, 29, 1, 30, tzinfo=london))
print(datetime(2026, 10, 25, 1, 30, tzinfo=london, fold=1))

Programs that schedule recurring events should store the recurrence rule and the zone, not just individual local readings, so transitions are handled by conversion rather than by manual adjustment. UTC never observes daylight saving, which is why it suits storage.

Check: Why is storing moments in UTC simpler than storing local times where daylight saving applies, and what does fold distinguish?

Section 19

Dates appear in programs in many guises, and recognising which guise a value wears is part of working with dates well. A value may be a date object, a datetime object, an ISO string, a timestamp number, or a formatted display string, and each guise suits a different stage of a program's work. Objects suit computation, ISO strings and timestamps suit storage and exchange, and formatted strings suit display. A value's guise should match its stage, and conversion between guises should happen at stage boundaries, explicitly, with the conversion's assumptions stated.

A common error is to keep a date as a string throughout a program, performing string operations on it — splitting on hyphens, comparing alphabetically, adding days by manipulating numbers in the string. That approach works for the simplest cases and fails for the rest: alphabetical comparison fails for non-ISO formats, day arithmetic fails at month boundaries, and time zone handling is impossible on a bare string. Converting to a date object at input, and back to a string only at output, replaces all of that fragile string manipulation with the module's tested operations.

Another common error is the reverse: converting a date to a string too early for display, and then needing to compute with it again, which requires parsing it back. Each conversion is a chance for error, especially when the display format is locale-dependent and the parser expects a different format. The discipline is to delay formatting until the last possible moment, and to keep the object form as long as any computation remains. A program that formats early pays for it in parsing code and in bugs.

Testing date code deserves a note. Date code has more edge cases than most code — leap years, month boundaries, time zone transitions, daylight saving gaps — and those edge cases are exactly the ones that ordinary testing with "today's date" never exercises. A test suite for date code should include explicit edge-case dates: the 29th of February, the 31st of December, the day clocks change, and a date in each half of the year. Writing those tests is cheap, and it catches the errors that only appear on unusual days.

from datetime import date
d = date(2026, 2, 28)
print(d.replace(year=2024))

Edge-case testing is especially valuable for date code, because the failures it catches occur rarely in production but cause real confusion when they do occur. Test with leap days, year boundaries, and transition days, not only with ordinary dates. A date test suite should include February 29, December 31, and a daylight saving transition day. Ordinary dates hide edge cases; explicit edge-case tests reveal them. Format dates at output, never earlier in the program. Keep objects inside, strings at the edges.

Check: Why should a program keep dates as objects internally and format them only at output, and which edge-case dates should date tests include?

Section 20

This chapter's second half turns to JSON, a text format for structured data that is used almost everywhere programs exchange information. JSON stands for JavaScript Object Notation, but despite its name it is language-independent: programs in every major language can read and write it, and it is the standard format for configuration files, for data files, and for messages between systems. A JSON document is text, human-readable, and composed of a small set of value types, which is why it is easy to learn and easy to debug by reading the document itself.

JSON's value types are few and map cleanly onto Python's basic types. A JSON object, written with braces, maps to a Python dictionary; a JSON array, written with brackets, maps to a Python list; a JSON string maps to a Python string; a JSON number maps to a Python integer or float; and the literals true, false, and null map to Python's True, False, and None. That small mapping is the whole of JSON's data model, and understanding it is understanding most of JSON: everything else is detail about how those values are written and read.

A JSON document is always a single value — an object, an array, or, less commonly, a single string, number, or boolean. Most documents are objects, since an object can hold named fields, but an array document is also common for lists of records. The document's text must follow JSON's syntax exactly: strings in double quotes, not single quotes; object keys in double quotes; no trailing commas; no comments. Those rules are stricter than Python's literal syntax, and they are a frequent source of errors when a programmer writes JSON by hand in Python style, with single quotes or trailing commas, and wonders why it fails to parse.

The json module in Python's standard library provides the tools for working with JSON, and the next sections introduce its core functions: loads() for parsing a JSON string into Python values, and dumps() for converting Python values into a JSON string. Those two functions, together with their file-based counterparts, are the foundation of JSON work in Python, and the rest of this chapter builds on them.

import json
print(json.loads('{"name": "Ada", "age": 36}'))
print(json.dumps({"name": "Ada", "age": 36}))

JSON's simplicity is deliberate: a format with few types is easier to implement correctly in every language, which is why it became the common exchange format it is today. A JSON document that a human cannot read is a document that is hard to debug, so readability is a real feature. JSON has no comments and no trailing commas, by design, keeping parsers simple and documents unambiguous. JSON's few types map cleanly onto Python's basic types.

Check: How do JSON's value types map onto Python types, and why does JSON written in Python style, with single quotes, fail to parse?

Section 21

The json.loads() function parses a JSON string into Python values, and its name is worth unpacking: "loads" is short for "load string", distinguishing it from load(), which reads from a file. Given the string '{"name": "Ada", "age": 36}', loads() returns the dictionary {"name": "Ada", "age": 36}, with the JSON object mapped to a dict, the JSON string to a Python string, and the JSON number to a Python integer. Given '[1, 2, 3]', it returns the list [1, 2, 3]. The conversion follows the type mapping from the previous section exactly, with no surprises for the basic types.

Parsing is strict, and strictness is a virtue here. loads() rejects strings that are almost JSON but not quite: single-quoted strings, trailing commas, unquoted keys, and comments all raise json.JSONDecodeError, which is a subclass of ValueError carrying details about where the problem lies — the message, the line and column, and the position in the string. A program that parses untrusted or user-supplied JSON should catch that error and report it clearly, rather than letting a traceback reach the user, and should never attempt to "fix" malformed JSON by guessing, since a guess about structure is a guess about meaning.

A useful property of loads() is that it accepts a JSON document as a string, as bytes, or as a byte array, decoding bytes as UTF-8 by default. That flexibility means a program that receives JSON over a network or from a file can often pass the raw bytes directly, without a separate decoding step. It also means that a program should be aware of encoding: JSON text is defined as Unicode, and bytes in another encoding will fail to decode or will decode incorrectly, so stating and checking the encoding at the boundary is part of parsing correctly.

Another property is that parsing produces fresh objects every time. Parsing the same string twice gives two equal but distinct dictionaries, so modifying one does not affect the other. That is the expected behaviour for a parsing function, but it is worth stating, because a program that parses a document once and shares the result widely should remember that the result is mutable: changes made through one reference are visible through all references to the same parsed object. Immutability, if needed, must be added by the program, for example by copying or by converting to a frozen structure.

Finally, loads() is the right tool for parsing a complete document held in memory. For very large documents, streaming parsers exist in external libraries, but for the documents a learner's programs handle — configuration, small data files, messages — loads() is simple, correct, and sufficient. Reach for it first, and only consider alternatives when a document's size genuinely demands them.

import json
data = json.loads('{"name": "Ada", "tags": ["dev", "admin"]}')
print(data["name"], data["tags"])
try:
    json.loads("{'name': 'Ada'}")
except json.JSONDecodeError as e:
    print("bad JSON:", e.msg)

Check: What information does a JSONDecodeError carry, and why should a program never guess at fixing malformed JSON?

Section 22

The counterpart of loads() is dumps(), short for "dump string", which converts Python values into a JSON string. Given the dictionary {"name": "Ada", "age": 36}, dumps() returns the string '{"name": "Ada", "age": 36}', with double quotes, a colon after each key, and a comma and space between items. Given a list, it returns a JSON array string. The output is compact by default, with minimal whitespace, which suits transmission and storage, though later sections show how to make it human-readable for debugging and for files a person will read.

Serialising with dumps() is also strict, in a different way. It accepts only values that map onto JSON types: dictionaries, lists, tuples (which become arrays), strings, numbers, booleans, and None. Passing a value of another type — a date, a set, a custom object — raises a TypeError, rather than silently converting it to something misleading. That strictness forces a program to decide explicitly how non-JSON values should be represented, which is the subject of a later section on the default parameter, and it prevents a class of bugs where a value is serialised in a form that cannot be read back correctly.

Dictionary keys in JSON must be strings, and dumps() enforces that by converting integer, float, boolean, and None keys to strings automatically, while rejecting keys of other types with a TypeError. That automatic conversion is convenient but deserves a note: a dictionary with integer keys {1: "a"} becomes the JSON object {"1": "a"}, and parsing it back gives string keys, not integer keys, so the round trip does not preserve the original key types. A program that relies on integer keys must handle that conversion explicitly, for example by converting keys back after parsing, or by choosing a different structure.

The output of dumps() is a string, and like any string it can be written to a file, printed, sent over a network, or stored in a database text column. It is not, by itself, a file format: a program that writes JSON to a file must choose a filename, handle writing errors, and decide on formatting, all of which are covered in the sections on file-based functions and on pretty-printing. dumps() itself does one job — conversion to a string — and does it well.

import json
print(json.dumps({"name": "Ada", "age": 36}))
print(json.dumps([1, 2, 3]))
try:
    json.dumps({1, 2, 3})
except TypeError as e:
    print("cannot dump:", e)

When dumping a structure for debugging, print the result with indent, so the shape of the data is visible at a glance rather than hidden in a single long line. Dumps converts Python values to a JSON string; it does not write files or validate shapes, which are separate steps.

Check: Why does dumps() raise TypeError for a set, and why does a round trip through JSON not preserve integer dictionary keys?

Section 23

The default output of dumps() is compact, but two keyword arguments make it human-readable, and they are worth learning early because readable JSON is debuggable JSON. The indent argument, given a number such as 2 or 4, inserts newlines and spaces so that each level of nesting is indented by that many spaces, producing the multi-line layout familiar from configuration files. The sort_keys argument, given True, outputs object keys in sorted order, which makes two documents with the same content produce identical text, a property that helps when comparing files or tracking changes in version control.

Pretty-printing with indent trades size for readability: an indented document is larger than its compact form, sometimes substantially so for deeply nested data, but it can be read and edited by a human without a formatting tool. For files that humans edit, such as configuration files, that trade is clearly worthwhile. For data transmitted between programs, where no human reads the text, compact output is better, since it is smaller and faster to transmit and parse. Choosing between them is a choice about who reads the output — a person or a program — and the answer should guide the argument, not habit.

Sorted keys deserve a separate note. Python dictionaries preserve insertion order, so dumps() without sort_keys outputs keys in the order they were inserted, which reflects the program's construction order. With sort_keys=True, the output order is alphabetical, which is deterministic regardless of construction order, and therefore better for comparing outputs from different runs or different programs. A program that writes JSON for version control should sort its keys, so that a change in the document's content, not a change in construction order, is what shows up as a difference.

A third argument, separators, controls the exact punctuation: by default, items are separated by ", " and keys from values by ": ", while passing (",", ":") produces the most compact form with no spaces at all. That compact form is occasionally useful for minimising size, but it sacrifices readability entirely, and it should be reserved for cases where size genuinely matters. For most programs, the default separators, with or without indentation, are the right choice.

import json
data = {"name": "Ada", "age": 36, "tags": ["dev"]}
print(json.dumps(data, indent=2, sort_keys=True))
print(json.dumps(data, separators=(",", ":")))

For files tracked in version control, combine indent with sorted keys, so diffs show content changes rather than formatting or ordering changes. Compact output suits transmission, while indented output suits human readers; choose according to who reads the text. Sorted keys make outputs deterministic across runs and programs. Indentation is for humans; compactness is for transmission. Choose formatting by audience: people get indent, programs get compact. Readability aids debugging.

Check: When is indented output worth its larger size, and why does sorting keys help when JSON files are kept in version control?

Section 24

Working with JSON files uses two more functions: load(), which reads a JSON document from a file object, and dump(), which writes Python values to a file object as JSON. The pattern is the same as for other file operations in this book: open the file with open(), in text mode, inside a with block, and call load() or dump() on the open file. Reading looks like with open("data.json") as f: data = json.load(f), and writing looks like with open("data.json", "w") as f: json.dump(data, f, indent=2). The with block ensures the file is closed even if parsing or writing fails.

The file-based functions accept the same keyword arguments as their string-based counterparts, so indentation, sorted keys, and separators work identically for files. A program that writes a configuration file for humans to read should pass indent=2, while a program that writes a data file for its own later use can omit it. Consistency matters here: a file written with one formatting should be read without caring about formatting, since parsing ignores whitespace, so a program can change its writing format without breaking its reading code.

Error handling for files combines file errors and JSON errors. Opening a missing file raises FileNotFoundError, reading a malformed file raises JSONDecodeError, and writing to a read-only location raises an OSError. A robust program handles each distinctly: a missing file might mean "use defaults", a malformed file means "the data is corrupt and a human should look at it", and a write failure means "the data could not be saved". Collapsing those into a single generic error message hides information the user needs to act correctly.

Encoding is another file concern. JSON files should be written and read as UTF-8, which is Python's default text encoding on most systems but should be stated explicitly with encoding="utf-8" in the open() call, so the program behaves identically on systems with different defaults. A file written in one encoding and read in another produces garbled strings or decoding errors, and stating the encoding removes that entire class of bug. It is a small habit — one keyword argument — with an outsized effect on portability.

import json
from pathlib import Path
Path("sample.json").write_text(json.dumps({"name": "Ada"}, indent=2), encoding="utf-8")
with open("sample.json", encoding="utf-8") as f:
    print(json.load(f))

When writing JSON files, write to a temporary file and replace the original only after success, so a failed write does not leave a half-written document behind. State the encoding explicitly when opening JSON files, so behaviour is identical on every system. A missing file, a malformed file, and a failed write need different messages. UTF-8 should be stated explicitly for every JSON file operation. Parsing ignores whitespace, so writing format can change freely. Close files with with blocks.

Check: Why should a JSON file be opened with an explicit UTF-8 encoding, and how should a program distinguish a missing file from a malformed one?

Section 25

JSON's type mapping is simple, but its details repay attention, because the mapping is where JSON and Python differ most visibly. JSON has six value kinds: objects, arrays, strings, numbers, booleans, and null. Python maps objects to dictionaries and arrays to lists, strings to strings, booleans to True and False, and null to None. Numbers are the interesting case: a JSON number without a fraction or exponent, such as 36, parses as a Python integer, while a number with a fraction or exponent, such as 36.5 or 1e3, parses as a Python float. That rule means a program can usually treat parsed numbers as it would any Python numbers, but it must remember that a value written as 1e3 arrives as a float, not as the integer 1000.

Strings in JSON follow Unicode rules: any Unicode character can appear, either directly or as an escape sequence such as \u00e9 for é. Python's loads() decodes those escapes into the corresponding characters, so a parsed string is an ordinary Python string with no escapes remaining. When writing, dumps() by default escapes non-ASCII characters into \u sequences, producing pure ASCII output that is safe for systems that handle only ASCII, at the cost of readability for non-English text. Passing ensure_ascii=False writes the characters directly, producing more readable output for human readers, provided the file or channel handles UTF-8, which it should.

Booleans and null also deserve a note about spelling. JSON writes them lowercase — true, false, null — while Python writes them capitalised — True, False, None. The json module translates between the two spellings automatically, but a programmer writing JSON by hand, or reading raw JSON text, must remember the lowercase forms, since Python-style capitals in JSON text are a syntax error. That is one of the small differences that makes hand-written JSON fail, and it is easily avoided by letting dumps() write the text rather than writing it by hand.

The mapping's simplicity is its strength: a document composed of these six kinds can be understood by any JSON reader, in any language, without additional schema or type information. Its limitation is also clear: anything outside the six kinds — dates, sets, custom objects, binary data — needs an explicit representation, which the next sections address.

import json
print(json.loads('{"a": 36, "b": 36.5, "c": true, "d": null}'))
print(json.dumps({"city": "Lagos"}, ensure_ascii=False))

JSON strings may contain escaped characters for control characters, such as newline as backslash-n, which loads() decodes back into the original characters automatically. Lowercase true, false, and null are JSON spellings; Python's capitalised forms are syntax errors in JSON text. Numbers without a fraction parse as integers; numbers with one parse as floats. Ensure_ascii True gives safe ASCII; False gives readable Unicode.

Check: How does loads() decide whether a JSON number becomes an integer or a float, and why does dumps() escape non-ASCII characters by default?

Section 26

Real JSON documents are rarely flat: they nest objects inside arrays, arrays inside objects, and objects inside objects, to represent structures of arbitrary depth. A document describing a small library, for example, might be an object with a "books" key whose value is an array of book objects, each with "title", "author", and "tags" fields, where "tags" is itself an array of strings. Parsing such a document gives a nested structure of dictionaries and lists, and working with it means navigating that nesting with successive indexing: data["books"][0]["title"] for the first book's title.

Navigating nested structures safely is a skill. Indexing a missing key raises KeyError, and indexing into a value of the wrong type raises TypeError, so a program that reads nested JSON from an outside source should not assume its shape. The dictionary .get() method, which returns a default when a key is absent, and careful checks on a value's type before indexing further, are the basic tools for defensive navigation. A later section covers validation more systematically, but the habit starts here: never index three levels deep into untrusted data without considering what happens if an intermediate level is missing or of the wrong type.

Constructing nested structures follows the same pattern in reverse. Building a document means building nested dictionaries and lists, and dumps() converts the whole structure in one call, however deep it is. That is simpler than building JSON text by hand, which requires tracking commas, brackets, and quoting at every level — a task that is tedious for shallow documents and error-prone for deep ones. The rule is the same as for dates: work with objects internally, convert to text only at the boundary.

Depth has a practical limit. Python's json module imposes a recursion limit on nesting, and extremely deep documents will fail to parse, which is a protection against malicious input designed to exhaust the parser's stack. Ordinary documents, even deeply nested ones, are far below that limit, so it rarely matters in practice, but it is worth knowing that the limit exists and why: a parser that recursed without limit could be crashed by a hostile document, and a library that guards against that is doing its users a favour.

import json
data = {"books": [{"title": "Dune", "tags": ["sci-fi"]}, {"title": "Emma", "tags": []}]}
text = json.dumps(data)
parsed = json.loads(text)
print(parsed["books"][0]["title"])

When navigating nested data from an outside source, check each level's type before indexing deeper, so a wrong-typed value produces a clear error instead of a confusing one. Build nested documents as nested Python objects, and let dumps() handle brackets, commas, and quoting for you. Defensive navigation uses get() and type checks before deep indexing. Nesting depth has a parser limit, protecting against hostile input.

Check: Why is navigating nested JSON from an outside source risky without defensive checks, and why does the parser limit nesting depth?

Section 27

Errors in JSON work fall into two families, and handling each correctly is part of writing robust programs. The first family is malformed text: a document that is not valid JSON at all, because of a syntax error, a trailing comma, or a truncated file. Parsing such text raises JSONDecodeError, whose attributes — message, line, column, and position — pinpoint the problem. A program should catch that error, report the file and the position, and, where possible, show the surrounding text, so that a human can find and fix the error. For a configuration file, that report is often the difference between a quick fix and a long hunt.

The second family is well-formed JSON with the wrong shape: a document that parses successfully but does not contain the fields a program expects, or contains them with the wrong types. Parsing succeeds, so no exception is raised at parse time, and the failure appears later, when the program indexes a missing key or operates on a value of the wrong type. Handling this family requires validation after parsing, which a later section covers, but the key insight is that successful parsing does not mean the data is usable. A program must check shape as well as syntax.

A third, subtler family is data that parses and has the right shape but wrong values: a negative age, an empty name, a date string that is not a valid date. Those are domain errors, not JSON errors, and they are caught by the same validation logic that checks shape, extended with checks on values. The progression from syntax to shape to values is a useful mental model for input handling in general, not just for JSON: each stage catches a different kind of bad input, and skipping a stage lets that kind through to later code, where it causes more confusing failures.

For writing, the main error is attempting to serialise an unsupported type, which raises TypeError at dumps() time. That error is usually a programming error rather than an input error — the program tried to serialise something it should have converted first — and the fix is to convert the value before dumping, or to supply a default function, covered next. In all cases, the principle is the same: catch errors at the boundary where data enters or leaves, report them clearly, and do not let malformed data propagate into the program's core logic.

import json
for text in ['{"a": }', '{"a": 1}']:
    try:
        print(json.loads(text))
    except json.JSONDecodeError as e:
        print("error at line", e.lineno, "column", e.colno, ":", e.msg)

Report JSON errors with the file name and position, so a human can locate the problem without searching the whole document.

Check: Why is successful parsing not enough to guarantee that JSON data is usable, and what are the three stages of input checking?

Section 28

Values that JSON cannot represent directly — dates, sets, custom objects — need an explicit conversion, and the default parameter of dumps() provides it. default takes a function that is called for any value the serialiser does not know how to handle, and that function must return a JSON-representable value, or raise TypeError if it cannot. The most common use is dates: default=str converts any unknown value with str(), turning a date into its ISO string, which is readable and, for dates, parseable back with fromisoformat(). That one argument makes a document containing dates serialisable with no other changes.

Using default=str broadly is convenient but blunt: it converts every unknown type with str(), including types whose string form is not a useful representation, such as a set, whose string form looks like "{1, 2}" but is not valid JSON and cannot be parsed back as a set. A more careful default function checks the value's type and handles each known case explicitly — dates to ISO strings, sets to sorted lists, for example — and raises TypeError for anything else, so that an unexpected type fails loudly rather than being silently misrepresented. That explicitness is worth the extra code whenever the document's contents matter.

The reverse direction — parsing a value back into its original type — has no automatic counterpart in loads(), because JSON text does not record the original Python type. A date stored as the string "2026-10-30" parses back as a string, not as a date, and a program that needs a date must convert it explicitly with fromisoformat() after parsing. That asymmetry is inherent to JSON: it stores values, not Python types, and the program must remember which fields are dates, which are numbers, and which are strings. Documenting that mapping, field by field, is part of designing a JSON format.

For custom objects, the cleanest approach is often to give the object a method that returns a dictionary representation, and to build the document from those dictionaries rather than passing objects to dumps() at all. That keeps the JSON representation explicit and testable, and it avoids relying on default for anything but the simplest conversions. The general principle is to make conversions visible in code, rather than hidden in a serialiser callback.

import json
from datetime import date
data = {"due": date(2026, 10, 30)}
print(json.dumps(data, default=str))
print(json.loads(json.dumps(data, default=str)))

A to-dictionary method on a custom object makes its JSON representation explicit and testable, which is better than relying on a generic default function. JSON stores values, not Python types, so the program must remember each field's intended type. Raise TypeError for unknown types in a default function, rather than misrepresenting them. Dates become ISO strings; sets become lists; the mapping must be explicit.

Check: Why is a careful default function better than default=str for documents with several non-JSON types, and why does parsing not restore a date automatically?

Section 29

Dates and JSON meet constantly in practice, because programs that store records as JSON almost always store dates among them, and the combination has a standard pattern worth learning. A record with a date field is stored with that field as an ISO string, produced by the date's isoformat() method or by dumps() with default=str, and read back by parsing the JSON and converting the string field with fromisoformat() or date.fromisoformat(). That pattern is simple, readable, and portable, since ISO strings are understood by every JSON reader.

The pattern has two variants, depending on whether the field is a day or a moment. A day field, such as a due date, is stored as a date ISO string like "2026-10-30" and parsed with date.fromisoformat(). A moment field, such as a creation timestamp, is stored as an aware datetime ISO string with its offset, like "2026-10-30T09:30:00+00:00", and parsed with datetime.fromisoformat(). Mixing the two variants — storing a moment as a date string, or a day as a datetime string — loses information or adds meaningless information, and a program should keep them distinct, just as it keeps date and datetime distinct in memory.

A subtlety is that default=str on a datetime produces its ISO string, which is convenient, but str() on a date also produces its ISO string, so the two cases look identical in the dumping code while differing in the parsing code. That is why the parsing side must know, field by field, which kind each field is: the JSON text alone does not say. A small helper that converts a parsed record — taking a dictionary of strings and returning a dictionary with dates and datetimes in their proper fields — centralises that knowledge in one place, where it can be tested and documented.

An alternative pattern is to store moments as timestamps, which are numbers in JSON, and convert them with fromtimestamp() on reading. Timestamps are more compact and language-neutral, but less readable: a human reading the JSON file cannot tell what moment a timestamp represents without converting it. For files humans read, ISO strings are better; for files only programs read, either is fine, and the choice should be documented alongside the file's format. The capstone chapters of this book use ISO strings in JSON storage, for exactly the readability reason.

import json
from datetime import date, datetime, timezone
record = {"due": date(2026, 10, 30), "created": datetime(2026, 10, 30, 9, 30, tzinfo=timezone.utc)}
text = json.dumps(record, default=str)
parsed = json.loads(text)
parsed["due"] = date.fromisoformat(parsed["due"])
print(parsed)

Keep the conversion between records and JSON in one helper, so the field-by-field type knowledge lives in a single, testable place. A day field uses a date string, and a moment field uses an aware datetime string; keep them distinct.

Check: Why must the parsing side know, field by field, whether a JSON string field is a date or a datetime, and when are timestamps a better stored form than ISO strings?

Section 30

JSON has limits, and knowing them prevents designs that JSON cannot support. The first limit is the type set itself: JSON has no date type, no set type, no tuple type, no bytes type, and no way to represent a custom object directly. Tuples become arrays on serialisation and lists on parsing, so a program that needs to distinguish tuples from lists must encode that distinction itself, for example by storing a type tag alongside the value. Sets become an error unless converted, and bytes must be encoded — commonly with base64, covered in the previous chapter — before they can appear in JSON at all.

The second limit is keys: JSON object keys must be strings. A Python dictionary with non-string keys loses information on a round trip, as noted earlier, and a program that needs non-string keys must represent them differently, for example as an array of key-value pairs. That representation is more verbose, but it preserves the keys exactly, and it is the standard workaround when a mapping's keys are not strings.

The third limit is precision. JSON numbers have no specified precision: a JSON parser in one language may read a number as a float, losing precision for very large integers, while Python's parser reads integers exactly. A program that exchanges very large integers as JSON numbers risks losing precision in the receiving system, and the safe workaround is to transmit such integers as strings, converting them back after parsing. That is a known pattern for identifiers and for financial quantities that exceed float precision, and it trades a small amount of convenience for exactness.

The fourth limit is size and structure: JSON has no comments, no way to reference another part of the document, and no way to express a value that appears twice without repeating it. A document that needs comments must keep them outside the JSON, for example in a separate documentation file, and a document with repeated structures must repeat them in full. Those limits keep JSON simple, and they are the price of that simplicity: formats that support comments, references, or richer types exist, but they are more complex, and JSON's success comes partly from refusing that complexity.

import json
print(json.dumps((1, 2)))
print(json.loads('[1, 2]'), type(json.loads('[1, 2]')))

JSON's simplicity is a trade-off: it gains universal support by refusing features like comments, references, and custom types that richer formats provide. Transmit very large integers as strings when the receiving system might parse numbers as floats. JSON keys must be strings, so non-string keys need another representation. No comments, no references, no custom types: that is JSON's bargain. Bytes need encoding, commonly base64, before entering JSON. Simplicity is JSON's strength. Know the limits.

Check: How should a program handle a Python tuple, a set, and a very large integer when storing them as JSON, and why?

Section 31

Displaying dates for users is a design decision, not just a formatting call, because different readers expect different conventions. A reader in one country expects day-first order, a reader in another expects month-first, and a reader in a third expects year-first. Spelling out the month name, as in "30 October 2026", avoids the day-month ambiguity entirely, and it is the safest choice for display to an unknown audience. Numeric formats should be reserved for audiences whose convention is known, or for machine-readable contexts where ISO format is the standard.

Locale settings affect more than month names. They affect the first day of the week, the separator characters in dates, and whether times use a 12-hour or 24-hour clock. A program that formats dates with strftime() inherits the machine's locale, which is correct for a program running on the user's own machine, but wrong for a program that generates output for users in other locales, such as a web service. Such a program should format dates explicitly for each user's locale, or use ISO format and let the display layer localise it, rather than relying on the server's locale.

Relative display is another option worth considering. Instead of showing an absolute date for recent events, a program can show "today", "yesterday", or "3 days ago", which is often more useful to a reader than a bare date. Computing a relative description means comparing the event's date with today's date and choosing a description based on the difference in days, with special cases for today and yesterday and a fallback to an absolute date for older events. That logic is short, and it makes interfaces friendlier, but it must be used carefully: for records, deadlines, and legal dates, the absolute date is what matters, and a relative description should never replace it.

A final display consideration is consistency within a program. Mixing formats — ISO dates in one place, month names in another, relative descriptions in a third — confuses readers, who must learn each format's meaning. A program should choose a small set of display formats, document them, and use each in its proper place: ISO for logs and files, spelled-out dates for user-facing text, relative descriptions for recent activity feeds. Consistency is a courtesy to readers, and it reduces the chance that a date is misread.

from datetime import date
d = date(2026, 10, 30)
print(d.strftime("%d %B %Y"))
print(d.strftime("%A, %d %B %Y"))

Choose a small set of display formats for a program, and use each in its proper place, so readers are never confused by mixed conventions. ISO format suits logs and files; spelled-out dates suit user-facing text. Relative descriptions suit recent activity; absolute dates suit records and deadlines.

Check: Why is spelling out the month name safer than a numeric date for an unknown audience, and when should a relative description never replace an absolute date?

Section 32

Accepting dates from users requires validation beyond parsing, because a string that parses as a date is not necessarily a sensible date for the program's purpose. A birth date in the future, a booking date in the past, or a deadline before a start date all parse correctly but violate the program's rules. Validation therefore has two stages: parsing, which checks that the string is a date at all, and domain checking, which checks that the date makes sense in context. Both stages should produce clear error messages that tell the user what is wrong and what would be right.

The order of checks matters. Parsing must come first, since domain checks cannot run on a value that failed to parse. After parsing, domain checks run in a sensible order: range checks first, such as "the date must not be in the future", then relationship checks, such as "the end date must not be before the start date". Each check should be a separate, named condition in code, so that a failing check produces a specific message rather than a generic "invalid date" that leaves the user guessing.

User input also arrives in unexpected forms: extra spaces, different separators, two-digit years, and month names in various cases. A robust input routine strips surrounding whitespace before parsing, and it may normalise separators, but it should not silently reinterpret ambiguous input. Two-digit years are a particular hazard, since "26" could mean 1926 or 2026, and a program that guesses will eventually guess wrong. Requiring four-digit years removes that ambiguity entirely, and it is a reasonable demand to make of users.

Where a program accepts dates repeatedly, it should centralise its parsing and validation in one function, used by every input path, so that the rules are consistent everywhere. A date accepted in one part of a program but rejected in another, because the two parts parse differently, is a bug that confuses users and undermines trust. One function, one set of rules, tested with valid, invalid, and edge-case inputs, is the pattern that prevents it.

from datetime import datetime, date
def parse_date(text):
    try:
        return datetime.strptime(text.strip(), "%Y-%m-%d").date()
    except ValueError:
        raise ValueError("expected a date like 2026-10-30")
print(parse_date(" 2026-10-30 "))

Centralise date parsing and validation in one function, used by every input path, so the rules are consistent throughout the program. Strip whitespace before parsing, and require four-digit years to avoid century ambiguity. Parsing first, domain checks second, each with a specific error message. One function, one set of rules, tested with valid, invalid, and edge-case inputs. Invalid dates deserve specific messages, not a generic 'invalid date'. Show the expected format in prompts, so users can correct input without guessing. Validate in context.

Check: Why must parsing come before domain checking, and why should a program require four-digit years rather than guessing a century?

Section 33

Business days are a common calendar concept that the standard library does not provide directly, because the definition of a business day varies: weekends differ by country, and holidays differ by country and by year. A program that needs business-day arithmetic must therefore implement it, using the weekday tools from earlier in this chapter and a holiday list it supplies itself. The core operation is stepping forward one day at a time, skipping days that are not business days, until the required number of business days has been counted.

That stepping approach is simple and correct for the small counts involved in business-day calculations — a deadline ten business days away requires at most a few weeks of stepping — and it avoids the complexity of closed-form formulas that must account for weekends and holidays analytically. Simplicity matters here, because a formula that is hard to verify is a formula that hides bugs, while a loop that steps day by day can be tested directly: starting on a Friday, adding one business day should land on Monday, and starting before a holiday should skip it.

A related operation is counting business days between two dates, which is the same stepping loop with a counter instead of a target. Both operations share a helper that tests whether a given date is a business day: not a weekend, and not in the holiday set. That helper is where the program's definition of "business day" lives, in one place, so that changing the definition — adding a holiday, changing the weekend — changes every calculation consistently.

Holidays deserve a note on sourcing. A program should not hard-code a holiday list that it never updates, since holidays change and new ones are declared. For a small program, a list supplied in configuration, reviewed annually, is adequate. For a larger program, a dedicated holiday dataset or service is appropriate, though choosing one is beyond this chapter's scope. The point for now is that holidays are data, not calendar logic, and they belong in data, where they can be updated without changing code.

from datetime import date, timedelta
HOLIDAYS = {date(2026, 11, 2)}
def is_business_day(d):
    return d.weekday() < 5 and d not in HOLIDAYS
def add_business_days(d, n):
    while n > 0:
        d += timedelta(days=1)
        if is_business_day(d):
            n -= 1
    return d
print(add_business_days(date(2026, 10, 30), 1))

Business-day rules vary by country and by year, so keep holidays in data that can be updated without changing code. Stepping day by day is simple and testable for the small counts business-day work involves. A business-day helper in one place keeps every calculation consistent. Holidays change over time, so a hard-coded list that never updates will go stale.

Check: Why is stepping day by day a good implementation for business-day arithmetic, and why do holidays belong in data rather than in code?

Section 34

Comparing datetimes across time zones is safe when both values are aware, because comparison converts them to a common reference, and it is meaningless when one is naive, because the module refuses to compare them. That rule, introduced earlier, has a practical consequence for programs that collect datetimes from several sources: every source must be converted to aware values, in its true zone, before any comparison, sorting, or range check. A program that sorts a mixed list of naive and aware datetimes will fail at the first comparison, and the fix is conversion at input, not special handling at comparison time.

A useful pattern is to normalise all moments to UTC immediately on input, storing and comparing only UTC values, and converting to local zones only for display. Normalisation makes comparison trivial — UTC values compare directly — and it removes an entire class of bugs where two values that represent the same moment compare unequal because they carry different offsets, or where values from different zones are compared as if their clock readings were comparable. The cost is one conversion per input, which is negligible compared with the bugs it prevents.

Sorting across zones also benefits from normalisation. A list of UTC datetimes sorts chronologically, regardless of which zones the original readings came from, while a list of local readings from different zones does not sort chronologically at all, since a later reading in one zone can precede an earlier moment in another. Any program that displays a timeline of events from several zones — a log viewer, a chat history, a travel itinerary — must normalise before sorting, or its timeline will be wrong in ways that are hard to notice and harder to debug.

Testing cross-zone comparison should include pairs of values that represent the same moment in different zones, which must compare equal, and pairs that represent different moments with misleading clock readings, such as a later clock reading in a western zone that is actually an earlier moment than an earlier reading in an eastern zone. Those two test cases catch the most common cross-zone mistakes: treating clock readings as moments, and treating equal moments as different because their representations differ.

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
a = datetime(2026, 10, 30, 9, 30, tzinfo=timezone.utc)
b = datetime(2026, 10, 30, 10, 30, tzinfo=ZoneInfo("Africa/Lagos"))
print(a == b, sorted([b, a]))

Normalise moments to UTC on input, and convert to local zones only for display, so comparison and sorting are always correct. Test cross-zone code with equal moments in different zones, and with misleading clock readings. Local readings from different zones do not sort chronologically without normalisation. Conversion at input costs little and prevents many bugs.

Check: Why must moments be normalised to UTC before sorting a timeline of events from several zones, and what two test cases catch cross-zone comparison mistakes?

Section 35

Calculating a person's age from their birth date is a classic date problem, and it is trickier than it looks, because age in years is not simply the difference in years between two dates. A person born on the 30th of October 1990 turns 36 on the 30th of October 2026, but on the 29th of October 2026 they are still 35, even though the year difference is already 36. The correct calculation subtracts the years and then subtracts one more if this year's birthday has not yet occurred, which is tested by comparing the current month and day with the birth month and day.

That comparison is expressed as a tuple comparison: (today.month, today.day) < (birth.month, birth.day) is true when this year's birthday is still in the future, and false when it has passed or is today. Tuple comparison works because it compares the month first and the day only when the months are equal, which is exactly the logic needed. The resulting code is short, readable, and correct, and it is a good example of how a small, well-chosen operation replaces a longer, error-prone calculation.

Leap-day birthdays add an edge case. A person born on the 29th of February has a birthday that does not occur in non-leap years, and different conventions place their birthday on the 28th of February or the 1st of March in those years. A program must choose a convention and document it, since the standard library does not choose for it. The common choice for age calculation is to treat the 28th of February as the birthday in non-leap years, which means the tuple comparison above needs adjustment for that case, or the birth date needs normalising before comparison. Either approach is acceptable if it is consistent and documented.

Age calculation also illustrates why date arithmetic should use date objects rather than day counts. Dividing a day difference by 365 gives an approximate age that is wrong near birthdays and wrong for leap years, while the year-difference-plus-adjustment method is exact. Approximations have their place — for example, in rough statistics — but for a person's age, exactness is expected, and the exact method is no harder to write than the approximate one.

from datetime import date
def age(birth, today):
    years = today.year - birth.year
    if (today.month, today.day) < (birth.month, birth.day):
        years -= 1
    return years
print(age(date(1990, 10, 30), date(2026, 10, 29)))
print(age(date(1990, 10, 30), date(2026, 10, 30)))

Age in years needs the birthday check, not just a year difference, since a birthday later in the year has not yet happened. Tuple comparison of month and day gives the birthday check in a single readable expression. Exact age calculation is no harder than the approximate day-count method.

Check: Why is dividing a day difference by 365 wrong for age calculation, and what convention must a program choose for leap-day birthdays?

Section 36

Finding the next occurrence of a recurring event — the next Monday, the next first of the month, the next anniversary — is a small calculation that uses the weekday and arithmetic tools already covered. The next Monday from a given date is found by computing how many days ahead the next Monday lies, using the weekday number, and adding that many days with a timedelta. The calculation must decide whether "next Monday" includes today when today is Monday: the usual convention is that it means the following Monday, not today, and that choice should be stated, since the alternative convention — the next Monday on or after today — is also reasonable and gives a different answer on Mondays.

Monthly recurrences use month arithmetic of the kind covered earlier: add one month, clamping the day if the target month is shorter, and repeat until the result is in the future. Annual recurrences use year arithmetic with the same leap-day consideration as age calculation. In both cases, the loop-until-future pattern is robust: start from the original date, step forward by the recurrence period, and stop at the first result after today. That pattern handles missed occurrences gracefully, since it steps past all of them, rather than assuming only one was missed.

Recurrence rules can be more complex — the second Tuesday of each month, the last Friday of each quarter — and those are built from the same primitives: month calendars to find a weekday within a month, and arithmetic to step between periods. A program that needs such rules should build them from small, tested helpers — a function for the nth weekday of a month, a function for stepping months — rather than writing one large function for each rule. Small helpers compose, and composed helpers are easier to test than monolithic rules.

Testing recurrence code should include the boundary cases: starting on the recurrence day itself, starting the day before and the day after, and starting across month and year boundaries. Those cases catch the off-by-one errors that recurrence code is prone to, where "next" is interpreted as "this" or where a month step lands in the wrong month. A recurrence function that passes its boundary tests is a function that can be trusted in a scheduler, which is a program where a wrong date has real consequences.

from datetime import date, timedelta
def next_monday(d):
    days_ahead = (7 - d.weekday()) % 7 or 7
    return d + timedelta(days=days_ahead)
print(next_monday(date(2026, 10, 30)))

State whether 'next' includes today, since the two conventions give different answers on the recurrence day itself. Build recurrence rules from small tested helpers, such as the nth weekday of a month. Boundary tests catch off-by-one errors in recurrence code.

Check: Why must a "next Monday" function state whether today counts when today is Monday, and why is the loop-until-future pattern robust for missed recurrences?

Section 37

Iterating over a range of dates — every day in a month, every day in a week, every day between two dates — is a common need, and it is implemented with a simple loop over a timedelta step. Starting from the first date, the loop yields the current date, adds one day, and repeats until it passes the last date. That pattern is short, clear, and correct, and it is preferable to computing day numbers by hand, which invites off-by-one errors at month and year boundaries that the timedelta loop handles automatically.

The loop's boundary conditions deserve care. An inclusive range — including both the start and end dates — is the usual intent for date ranges, since a report for "October" should include both the 1st and the 31st, but a loop that stops when the current date equals the end date excludes the end, producing a report missing its last day. Stating the inclusivity in the function's name or documentation, and testing with a range of a single day, which should yield exactly that day, catches the most common off-by-one error in date iteration.

Generating dates as a generator, rather than building a list, is the better implementation for long ranges. A generator yields one date at a time, using constant memory, while a list of every day in a year uses memory for 365 date objects, which is wasteful when the caller processes them one at a time. The generator pattern from the iterators chapter applies directly here, and it is a good example of how chapters of this book connect: a technique learned for one purpose serves another without modification.

Date ranges also combine with filtering: iterating over a month's days and keeping only business days, or only Mondays, is a loop plus a condition, and the condition is the weekday or business-day test from earlier sections. That composition — generation plus filtering — is the general pattern for date-based reporting, and it keeps each part simple: the generator knows about stepping, the filter knows about selection, and neither needs to know about the other.

from datetime import date, timedelta
def daterange(start, end):
    d = start
    while d <= end:
        yield d
        d += timedelta(days=1)
print(list(daterange(date(2026, 10, 30), date(2026, 11, 2))))

Test a date range with a single day, which should yield exactly that day, to catch off-by-one errors at the boundaries. A generator yields dates one at a time, using constant memory even for long ranges. Generation plus filtering is the general pattern for date-based reporting. Inclusive ranges include both the start and the end date. A single-day range should yield exactly that day. Stepping with timedelta handles month and year boundaries automatically.

Check: Why should a date-range generator be inclusive of both ends, and why is a generator better than a list for a long date range?

Section 38

JSON's simplicity makes it safe in one important respect: parsing JSON never executes code. The json module builds dictionaries, lists, strings, and numbers from text, and nothing in a JSON document can cause arbitrary code to run during parsing. That is a deliberate property of a data format, and it is why JSON is safe to parse from untrusted sources, provided the program validates the parsed data's shape and values before acting on it. A document that parses successfully can still contain harmful values — a path that escapes a directory, a command string, a huge number — but those harms come from what the program does with the values, not from parsing itself.

That safety contrasts with Python's pickle format, which can execute code during loading and must never be used on untrusted data. A program that receives serialised data from outside should use JSON, not pickle, precisely because JSON's parser cannot be tricked into running code. The distinction is a security fundamental: data formats that can execute code are dangerous for untrusted input, while data formats that only describe values are safe to parse, with validation still required for the values themselves.

Validation after parsing is therefore the security step for JSON, and it should be systematic. A program should check that expected fields exist, that they have the expected types, and that their values fall within acceptable ranges, before using them. A field that should be a string but arrives as a list, or a number that arrives as a string, should be rejected with a clear error, not coerced silently, since silent coercion can mask an attacker's attempt to smuggle an unexpected value past a check. Explicit validation, field by field, is the pattern that keeps JSON handling safe.

Size is another consideration. A JSON document can be large, and parsing a very large document uses memory proportional to its size, since the whole parsed structure is held in memory. A program that accepts JSON from untrusted sources should limit the input size before parsing — for example, by reading at most a fixed number of bytes — so that a hostile document cannot exhaust the program's memory. That limit is a simple, effective guard, and it belongs at the boundary where input is received, before parsing begins.

import json
text = '{"name": "Ada", "role": "admin"}'
data = json.loads(text)
if not isinstance(data.get("name"), str):
    raise ValueError("name must be a string")
print(data["name"])

Parsing JSON cannot execute code, but the values it returns must still be validated before the program acts on them. Limit input size before parsing untrusted JSON, so a hostile document cannot exhaust memory. Reject wrong-typed values explicitly, rather than coercing them silently. Data formats that execute code are dangerous for untrusted input.

Check: Why is parsing JSON safe from code execution while unpickling is not, and why must parsed JSON still be validated before use?

Section 39

Validating a JSON document's shape means checking, after parsing, that the document has the structure a program expects, and doing so before the program uses any of its values. The simplest validation is a series of explicit checks: the document must be a dictionary, it must contain certain keys, those keys' values must be of certain types, and nested structures must satisfy the same checks recursively. Writing those checks by hand is straightforward for small documents, and it has the advantage that the checks are visible in code, where a reader can see exactly what shape is expected.

For larger documents, hand-written checks become repetitive, and a schema — a description of the expected shape, written as data — becomes attractive. A schema states, for each field, its expected type, whether it is required, and any constraints on its value, and a validator checks a document against that schema, reporting every mismatch. Schema validation libraries exist outside the standard library, and a later chapter may introduce them, but the concept is worth understanding now: a schema separates the description of valid data from the code that checks it, so that changing the expected shape means changing the schema, not rewriting validation code.

Whether validation is hand-written or schema-based, two principles apply. First, validate at the boundary, where data enters the program, and then trust the validated data internally, rather than re-checking it at every use. Re-checking everywhere clutters code and suggests that the boundary check cannot be trusted. Second, report all errors, not just the first, where practical, so that a user fixing a document does not have to submit it repeatedly to discover each error in turn. Reporting all errors is friendlier and faster, though it requires collecting errors rather than raising on the first one.

A final validation note concerns extra fields. A document may contain fields a program does not expect, because it was written by a newer version of another program, or because it carries information for a different consumer. A program should generally ignore extra fields rather than rejecting them, since rejecting them breaks forward compatibility: a document that gains a new field should still work with an older reader that does not know that field. Rejecting unknown fields is appropriate only when strictness is a security requirement, and that choice should be deliberate.

import json
def validate_user(data):
    if not isinstance(data, dict):
        raise ValueError("expected an object")
    if not isinstance(data.get("name"), str) or not data["name"]:
        raise ValueError("name must be a non-empty string")
    if not isinstance(data.get("age"), int):
        raise ValueError("age must be an integer")
    return data
print(validate_user(json.loads('{"name": "Ada", "age": 36}')))

Validate at the boundary, then trust validated data internally, rather than re-checking it at every use. Extra fields should usually be ignored, for forward compatibility.

Check: Why should validation happen at the boundary rather than at every use, and why should extra fields usually be ignored rather than rejected?

Section 40

A JSON document's fields can be absent, or present with the value null, and those two states mean different things that a program should distinguish. An absent field means the document says nothing about that field: it may have been omitted because it does not apply, because the writer did not know it, or because an older writer predates the field. A field present with null means the document explicitly says the field has no value: it applies, but it is empty, unknown, or intentionally blank. Treating those two states identically loses information that the writer deliberately included.

In Python, the distinction is visible through the dictionary API: "field" in data tests presence, while data.get("field") returns None both for an absent field and for a field present with null, conflating the two states. A program that needs the distinction must test presence first, with in, and only then read the value. That two-step pattern is slightly more verbose than a single .get(), but it preserves a distinction that can matter: a user profile with no "nickname" field has never set one, while a profile with "nickname": null has explicitly cleared one, and a program that prompts differently in those two cases needs to know which is which.

Default values interact with this distinction. A program that supplies a default for a missing field — for example, treating a missing "theme" as "light" — should decide whether an explicit null also means the default, or whether it means "no theme". The usual choice is that null means "no value", which is different from the default, but some programs treat them identically for simplicity. Either choice is defensible if it is consistent and documented, but the choice should be made deliberately, not by accident through a .get() call that conflates the cases.

The broader lesson is that JSON's null is a value, not an absence, and that a program's data model should reflect that. A field with three states — absent, null, and valued — needs handling for all three, and a program that collapses them to two will eventually misinterpret a document that uses the third state meaningfully. Checking presence explicitly is a small habit that prevents that class of bug.

import json
a = json.loads('{"name": "Ada"}')
b = json.loads('{"name": "Ada", "nickname": null}')
print("nickname" in a, "nickname" in b, b.get("nickname"))

An absent field says nothing; a null field explicitly says no value, and programs should distinguish those two states. Test presence with in, not with get(), when the absent-versus-null distinction matters. Null is a value, not an absence, and data models should reflect that. Document how a program treats absent versus null fields. Three states need handling: absent, null, and valued.

Check: What is the difference between a JSON field that is absent and one present with null, and why does .get() conflate them?

Section 41

Choosing JSON as a storage format is a decision that should be made deliberately, by comparing it with the alternatives a program might use. JSON suits structured data with named fields, nested structures, and a mix of types — records, configuration, messages — where readability matters and where the data will be read by programs in different languages. It suits those cases better than a flat, line-based format, which cannot represent nesting naturally, and better than a binary format, which cannot be read by a human debugging a problem.

JSON is less suited to tabular data with many uniform rows, where a row-based format is more compact and where tools for tables are more convenient. It is also less suited to data that is primarily prose, where a document format is more natural, and to data with rich types — dates with time zones, binary blobs, precise decimals — where JSON's six types require workarounds that add complexity. A program that forces such data into JSON without acknowledging the workarounds will accumulate conversion code that obscures its logic.

A useful test for the choice is to write a small sample document in JSON and read it as a human. If the sample is clear, if its fields are self-explanatory, and if its nesting matches the data's natural structure, JSON is a good fit. If the sample is awkward — if fields need type tags, if numbers need string wrappers, if repeated structures dominate — then another format, or a different JSON structure, may be better. The sample test is cheap, and it catches format mismatches before they are baked into a program's storage.

Format choices also have a lifetime dimension. Data stored in JSON today may need to be read by a program written years from now, in a language not yet chosen, and JSON's universality is an asset there: a future reader will certainly have a JSON parser. A custom format, however clever, requires its future reader to implement a parser from scratch, which is a burden that grows with time. For long-lived data, a standard format's universality outweighs its limitations, provided those limitations are handled explicitly.

import json
sample = {"title": "Report", "pages": 12, "published": True}
print(json.dumps(sample, indent=2))

A sample document written by hand is a cheap test of fit: if it reads clearly and its nesting matches the data, JSON suits the task. JSON suits named fields and nesting; it suits tabular data and rich types less well. Standard formats outlive custom ones, because future readers already have parsers for them. If a sample document needs type tags everywhere, another format may fit better. Choose JSON deliberately, comparing it with alternatives for the data at hand.

Check: What sample test helps decide whether JSON fits a program's data, and why does JSON's universality matter for long-lived data?

Section 42

A small configuration file in JSON is one of the most common uses of the format, and the basic pattern is worth learning well. A program reads its configuration at startup with json.load(), obtains a dictionary, and reads settings from it with .get() calls that supply defaults for missing keys. That pattern gives a program sensible behaviour when a configuration file is absent or incomplete: missing settings fall back to defaults, rather than crashing, while present settings override them. The defaults should be chosen carefully, since they define the program's behaviour for users who never edit the configuration.

Defaults also interact with the absent-versus-null distinction from an earlier section. A missing key should usually mean "use the default", while an explicit null may mean "no value" or may also mean the default, depending on the program's documented choice. A configuration reader that uses .get(key, default) treats both cases as the default, which is the simpler choice and is acceptable when the distinction does not matter for that setting. For settings where it does matter — for example, an optional output path, where null might mean "no output" rather than "the default output" — the reader should test presence explicitly, as described earlier.

Validation for configuration follows the same pattern as for any JSON input: check that the document is a dictionary, that known settings have the expected types, and that values fall within acceptable ranges, reporting problems with the file name and the offending setting. A configuration error reported at startup, with a clear message, is far easier to fix than a configuration error that surfaces halfway through a run, when the misconfigured setting is first used. Startup validation is therefore a good investment, even for small programs.

Configuration files should also be versioned, in the sense that a program should tolerate a configuration file written for an older version, by ignoring unknown settings and supplying defaults for new ones. That tolerance is what lets a program evolve without breaking existing users' configurations, and it follows directly from the principles of ignoring extra fields and defaulting missing ones. A program that rejects any configuration it does not exactly recognise will frustrate its users at every upgrade.

import json
config = json.loads('{"theme": "dark"}')
print(config.get("theme", "light"))
print(config.get("font_size", 12))

Startup validation catches configuration errors early, when they are cheap to fix, rather than halfway through a run. Missing settings fall back to defaults; present settings override them, by design. Configuration tolerance lets a program evolve without breaking existing users. A clear startup error beats a mysterious mid-run failure for configuration problems. Defaults define behaviour for users who never edit configuration. Ignore unknown settings for forward compatibility. Supply defaults. Validate early. Done.

Check: Why should a configuration reader supply defaults for missing keys, and why should it tolerate unknown settings from newer versions?

Section 43

Writing a configuration file, or any JSON file a program will read back, should be done safely, because a failed or interrupted write can corrupt the file and lose the program's settings or data. The safe pattern is to write to a temporary file in the same directory, flush and close it, and then replace the original file with the temporary one in a single atomic operation. If the program crashes during writing, the original file remains intact; if it crashes during replacement, the replacement either happens or it does not, but the file is never left half-written. That pattern was introduced for text files in an earlier chapter, and it applies with equal force to JSON files.

Formatting choices for written files follow the audience rule from earlier sections: files humans edit should be indented and sorted, while files only programs read can be compact. A configuration file is a human-edited file, so it should be written with indent=2 and sort_keys=True, making it readable and making its changes easy to track. A data file that a program writes and reads for itself can use compact output, though indented output costs little for small files and aids debugging, so many programs indent even their data files.

Writing should also be validated, in the sense that a program should verify that what it wrote can be read back. A simple check — parsing the written file, or at least confirming that the write completed without error — catches disk-full errors and permission errors that would otherwise surface later as a mysterious failure to load. For important data, that check is worth its small cost, and it turns a silent corruption into a reported error at the moment when the program can still retry or warn the user.

Finally, a program should not rewrite a configuration file merely because it read it. Rewriting on every run, even with identical content, changes the file's modification time and can overwrite a user's formatting or comments — though JSON has no comments, a user's chosen formatting is still theirs. A program should write a configuration file only when its content actually changes, or when the user explicitly asks it to save, preserving the file's existing form otherwise.

import json, os
from pathlib import Path
data = {"theme": "dark"}
tmp = Path("config.tmp.json")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
os.replace(tmp, "config.json")
print(Path("config.json").read_text(encoding="utf-8"))

Verify a written file by reading it back, so disk-full or permission errors surface immediately rather than as a later load failure. A program should write a file only when its content actually changes. Temporary-file writing protects against crashes during a write. Human-edited files deserve indentation and sorted keys for readability. Indented output aids debugging even for program-only data files.

Check: Why should a JSON file be written via a temporary file and atomic replacement, and why should a program not rewrite a configuration file it merely read?

Section 44

A record that combines dates with JSON storage is a small design exercise that pulls together most of this chapter. Consider a simple event record: a title, a date, and a creation timestamp. In memory, the record is a dictionary with a date object and an aware datetime object. In JSON, it is an object with the date as an ISO date string and the timestamp as an ISO datetime string with its offset. Converting between those two forms is the job of two small helpers: one that turns a record into a JSON-ready dictionary, and one that turns a parsed dictionary back into a record, converting the string fields with fromisoformat().

The helpers should be inverses of each other: converting a record to JSON and back should give a record equal to the original, with the same date and the same moment. Testing that round trip, with edge-case dates such as leap days and with timestamps in different zones, verifies the helpers and catches conversion mistakes, such as a date parsed as a datetime or a timestamp stripped of its offset. Round-trip tests are cheap and powerful, and they should be written for any conversion pair, not just for dates and JSON.

The record's JSON form should also be documented, field by field, stating each field's meaning, its type in JSON, and, for date fields, whether it is a day or a moment and which time zone convention it follows. That documentation is what lets a future reader — including the program's own author, months later — understand the file without reverse-engineering the conversion code. A format without documentation is a format that will be misread, and misread data is worse than unreadable data, because it produces confident, wrong results.

Extending the record illustrates the same principles at larger scale. Adding a field means adding it to both helpers and to the documentation, and old files without the new field must still load, with a sensible default for the missing field. That forward-compatibility discipline, applied field by field, is what keeps a JSON format usable as a program evolves, and it is the practical payoff of the validation and defaulting principles from earlier sections.

import json
from datetime import date
record = {"title": "Launch", "due": date(2026, 10, 30)}
text = json.dumps(record, default=str)
parsed = json.loads(text)
parsed["due"] = date.fromisoformat(parsed["due"])
print(parsed)

Document each field's meaning, JSON type, and date convention, so future readers understand the file without reverse-engineering code. Old files without a new field must still load, with a sensible default. Round-trip tests verify conversion helpers with edge-case dates and zones. Adding a field means updating helpers and documentation together. Documented formats are misread less often than undocumented ones.

Check: Why should record-to-JSON and JSON-to-record helpers be tested as a round trip, and what should a record format's documentation state for each date field?

Section 45

A checklist for time zone handling, applied whenever a program stores or compares moments, prevents most time zone bugs, because those bugs come from a small set of repeated mistakes rather than from exotic cases. The checklist has five items. First, decide whether each value is a day or a moment, and use date for days and aware datetime for moments. Second, store moments in UTC, with local conversion only for display. Third, never compare a naive value with an aware value, and convert at input so that comparisons never encounter the mix. Fourth, never label a naive value with a zone it did not come from; construct aware values in their true zone, or convert properly with astimezone(). Fifth, test with zones that observe daylight saving and with zones that do not, and with dates in both halves of the year.

Each item on the checklist corresponds to a mistake this chapter has described, and the checklist's value is that it turns those descriptions into a routine. A programmer who runs through the five items for each new feature that touches time will catch the common mistakes before they reach testing, and a reviewer who checks a change against the list will catch the ones the programmer missed. Checklists work because they externalise knowledge: they do not require remembering every pitfall, only remembering to consult the list.

The checklist also applies to reviewing existing code. A program that already stores local times, or that mixes naive and aware values, will show its problems when checked against the list, and each problem suggests its own fix: local storage suggests migration to UTC, mixed values suggest conversion at input, mislabelled values suggest reconstruction in the true zone. Migrating stored data, as noted earlier, requires knowing the old format's assumptions, and the checklist's documentation habit — recording each stored value's meaning and zone — is what makes that migration possible.

Time zone bugs are famously confusing, because they appear only for users in certain zones, or only on certain days of the year, and they are therefore hard to reproduce. A checklist does not eliminate that difficulty, but it reduces the number of bugs that reach production, and it gives a debugging starting point when one does: check each item in turn, and the violated item usually points to the fault.

from datetime import datetime, timezone
dt = datetime(2026, 10, 30, 9, 30, tzinfo=timezone.utc)
print(dt.isoformat())

Run through the checklist for every new feature that touches time, and use it when reviewing existing code for time zone problems. Time zone bugs appear only for some users or on some days, making them hard to reproduce. UTC storage plus local display confines daylight saving complexity to one step.

Check: What are the five items on the time zone checklist, and why does a checklist help more than trying to remember every pitfall?

Section 46

JSON objects cannot contain duplicate keys in any meaningful sense, because an object is a mapping from keys to values, and a key can map to only one value. Yet JSON text can contain the same key twice, as in {"a": 1, "a": 2}, and a parser must decide what to do with such text. Python's json.loads() keeps the last value, returning {"a": 2}, silently discarding the first. That behaviour is specified and predictable, but it is also silent, which means a document with duplicate keys — usually a mistake, occasionally a sign of tampering — parses without any warning, and the discarded value is lost without a trace.

Whether that silence matters depends on the program. For most programs, duplicate keys never occur, and the last-wins rule is harmless. For programs that accept JSON from untrusted sources, or that merge documents from several writers, duplicate keys could hide a discrepancy: two writers assigning different values to the same key, with only the last surviving. A program that cares about that case can detect duplicates by supplying an object_pairs_hook to loads(), a function that receives the object's key-value pairs as a list, preserving duplicates, and that can raise an error or handle them explicitly. That hook is an advanced feature, but knowing it exists is useful, because it turns a silent behaviour into a detectable one.

The broader lesson is that a parser's handling of edge cases is part of its contract, and a program that relies on a format should know that contract's edge cases, not just its common cases. Duplicate keys, numbers with extreme precision, deeply nested structures, and non-string keys are all edge cases where parsers differ, and a program that exchanges data with other systems should test those cases against the other system's parser, rather than assuming identical behaviour.

For ordinary programs, the practical advice is simple: never write duplicate keys, since no reader is obliged to preserve them, and be aware that reading a document with duplicates gives you the last value only. That awareness is enough for most purposes, and the object_pairs_hook is there for the cases where it is not.

import json
print(json.loads('{"a": 1, "a": 2}'))
def no_dupes(pairs):
    keys = [k for k, _ in pairs]
    if len(keys) != len(set(keys)):
        raise ValueError("duplicate key")
    return dict(pairs)
print(json.loads('{"a": 1}', object_pairs_hook=no_dupes))

Never write duplicate keys, since no reader is obliged to preserve them, and the last value is what a Python reader will return. Duplicate keys are usually a mistake, occasionally a sign of tampering, and always silent. Object_pairs_hook preserves duplicate pairs, turning silent loss into a detectable error. Parsers differ on edge cases, so test them against the other system's parser. Awareness of edge cases is enough for most programs.

Check: What does json.loads() do with duplicate object keys, and how can a program detect them instead of silently accepting the last value?

Section 47

Numbers in JSON deserve a final note on precision, because the interaction between JSON numbers and Python numbers has a trap for money and for large identifiers. JSON numbers are written in decimal, and Python parses integers exactly, however large, but parses numbers with fractions or exponents as floats, which have limited precision. A JSON number like 9007199254740993, an integer beyond the range where floats represent every integer exactly, parses in Python as the exact integer, but if that document is read by a system that parses all numbers as floats, the value will be rounded to a nearby float, and the identifier will change silently. That is why large identifiers are often transmitted as strings in JSON, as noted earlier.

Money has a related trap. A JSON number like 19.99 parses as the float closest to 19.99, not as the exact decimal 19.99, and arithmetic on that float can produce results like 19.99 + 5.00 = 24.989999999999998 in floating-point display, which is not the exact decimal result. A program that handles money should therefore not store monetary amounts as JSON numbers, but as strings representing exact decimals, or as integer counts of the smallest currency unit, such as cents, converting to decimals only for display and calculation with the decimal module from the previous chapter. That pattern — strings or integer cents in JSON, decimals in memory — keeps money exact through storage and exchange.

The general principle is that JSON numbers are suitable for quantities where float precision is acceptable — measurements, counts within float range, scores — and unsuitable for quantities where exactness is required — money, large identifiers, precise decimals. Recognising which category a quantity falls into, before choosing its JSON representation, is a design decision that should be made explicitly and documented, since changing it later requires migrating stored data.

A program that receives JSON numbers from outside should also consider what happens when a number arrives in an unexpected form: an integer where a float was expected, or a float where an integer was expected. Python's dynamic typing means both will parse, and the program's validation should check the type it actually needs, converting where the conversion is safe and rejecting where it is not, rather than assuming the sender's number form matches its own.

import json
from decimal import Decimal
data = json.loads('{"price": "19.99"}')
print(Decimal(data["price"]) + Decimal("5.00"))

Choose a JSON representation for money explicitly, and document it, since changing it later requires migrating stored data. Float precision is acceptable for measurements, but not for money or large identifiers. Integer cents in JSON, decimals in memory, keeps money exact through storage. Validation should check a number's type, converting only where safe. Money as strings or cents: decide explicitly, document it.

Check: Why should money be stored in JSON as a string or as integer cents rather than as a JSON number, and what happens to a large integer in a system that parses all numbers as floats?

Section 48

Dates from different sources must be normalised before they are compared, because sources differ in format, in time zone, and in whether they represent days or moments. One source may provide ISO strings in UTC, another may provide local readings without a zone, and a third may provide timestamps. Comparing those raw forms directly is meaningless: a string comparison of ISO dates in different zones does not give chronological order, a timestamp compared with a datetime raises a type error, and a naive local reading compared with an aware UTC moment raises the naive-aware error. Normalisation — converting every source's values to aware UTC datetimes, or to dates, as appropriate — is the step that makes comparison possible.

Normalisation should happen at input, in a dedicated function per source, so that the rest of the program deals only with normalised values. Each source's function knows that source's format and zone, performs the conversion, and documents its assumptions, including what it does with ambiguous inputs. That separation keeps source-specific quirks out of the program's core logic, which then operates on clean, uniform values, and it makes adding a new source a matter of writing one more normalisation function, rather than modifying comparison code throughout the program.

A related problem is dates that arrive as strings in different formats from different sources — one source writing day-first, another month-first, a third ISO. Normalisation for those sources includes parsing with the correct format for each source, and the format for each source should be stated in that source's function, not guessed at parse time. Guessing a format from the string itself is unreliable, since many strings are valid in several formats with different meanings, and a guess that is wrong produces a confidently wrong date.

Testing normalisation should use the same moment expressed in each source's form, verifying that all forms normalise to the same value, and should include edge cases for each source, such as its daylight saving transitions and its leap-day handling. Those tests verify the normalisation functions independently of the rest of the program, and they catch source-specific mistakes — a wrong zone, a wrong format, a missing offset — at the boundary where they occur, rather than as mysterious comparison failures later.

from datetime import datetime, timezone
a = datetime.fromisoformat("2026-10-30T09:30:00+00:00")
b = datetime.fromtimestamp(a.timestamp(), tz=timezone.utc)
print(a == b)

Each source's normalisation function should state its format and zone, and handle ambiguous inputs by rejecting them rather than guessing. Test normalisation with the same moment in each source's form, verifying a single result. Guessing a date format from the string itself produces confidently wrong dates. Normalisation at input keeps core logic free of source-specific quirks. One normalisation function per source keeps quirks contained.

Check: Why must dates from different sources be normalised at input before comparison, and why should each source have its own normalisation function?

Section 49

A small event log brings together the chapter's two halves in a single, realistic design. The log is a JSON file containing a list of event records, each with a title, a creation timestamp as an aware ISO string in UTC, and an optional due date as an ISO date string. The program appends events by loading the file, adding a record, and writing the file back safely via a temporary file, and it lists events by loading the file, converting the string fields to datetime and date objects, and sorting by the creation timestamp. That design is short, readable, and uses every major idea in this chapter: aware UTC timestamps, ISO strings, JSON files, safe writing, and normalisation at input.

The design also illustrates the chapter's discipline about boundaries. Dates enter the program as strings from the file and are converted to objects immediately on loading; they leave the program as strings when the file is written, converted from objects at that moment. Inside the program, between loading and writing, all date work uses objects, with no string manipulation of dates anywhere. That discipline is what keeps the program's date logic correct, and it is visible in the code's structure: conversion code sits in the loading and saving functions, and nowhere else.

Error handling for the log follows the patterns from earlier sections. A missing file means an empty log, since no events have been recorded yet. A malformed file means the log is corrupt, and the program should report the file and the parse error rather than silently starting over, since silently discarding a user's events would be worse than reporting the problem. A failed write means the new event was not recorded, and the program should report that failure rather than claiming success. Each case has a distinct, honest response, and none of them hides data loss.

Extending the log is straightforward, because its format is documented field by field and its conversion helpers are centralised. Adding a field means adding it to the record helpers and to the documentation, and old log files without the new field load with a default. That is the same forward-compatibility discipline as for configuration files, applied to a data file, and it is the reason the log design scales from a learner's exercise to a real tool without restructuring.

import json
from datetime import datetime, timezone
event = {"title": "Launch", "created": datetime(2026, 10, 30, 9, 30, tzinfo=timezone.utc).isoformat()}
print(json.dumps(event))

Conversion code belongs in loading and saving functions, and nowhere else, keeping date logic clean and centralised. A missing log file means an empty log; a malformed one means corruption to report. Distinct, honest responses for missing, malformed, and unwritable logs prevent hidden data loss.

Check: Where in an event-log program should date conversion happen, and why should a malformed log file never be silently replaced with an empty log?

Section 50

This chapter has covered two topics that belong together because programs so often use them together: dates and times, and JSON. The date half introduced the datetime module's types — date, time, and datetime — the distinction between naive and aware values, time zones and daylight saving, arithmetic with timedelta, formatting and parsing, and the discipline of storing moments in UTC, converting at boundaries, and testing edge cases. The JSON half introduced the format's six value types, the json module's core functions — loads() and dumps() and their file counterparts — formatting options, error handling, validation, and the limits that require explicit conversion for dates, sets, and other non-JSON types.

The principles that recur across both halves are the same principles that recur across this book. Work with objects internally and convert to text only at boundaries. Validate input at the boundary, in stages from syntax to shape to values, and report errors clearly. Choose representations deliberately — a date for a day, an aware datetime for a moment, an ISO string for stored text, a string for money in JSON — and document those choices where the data is defined. Prefer standard formats and standard tools over custom ones, because their edge cases have already been considered and their behaviour is shared with every other program that uses them.

The next chapter, on regular expressions, turns to a different kind of text work: finding and manipulating patterns within strings, which complements this chapter's structured text work. Later chapters return to dates and JSON in applied settings — data handling, APIs, and the capstone project, which stores its tasks and notes as JSON with ISO dates, using exactly the patterns this chapter introduced. The foundation laid here is therefore not an end in itself, but the basis for work that follows, and it repays revisiting whenever a later chapter's date or JSON code raises a question about why a value is represented as it is.

A good way to consolidate this chapter is to build the event log from the previous section, or a small diary program that stores dated entries as JSON, and to test it with edge-case dates, with time zone conversions, and with malformed files. A program that survives those tests demonstrates the chapter's lessons in working code, which is a stronger proof of understanding than any single section's Check question.

import json
from datetime import date
print(json.dumps({"today": date(2026, 10, 30).isoformat()}))

Work with objects inside, convert at boundaries, validate at input, and prefer standard formats: those disciplines recur across both halves of this chapter. The capstone stores tasks and notes as JSON with ISO dates, using this chapter's patterns. Build the event log or a dated diary program, and test it with edge cases and malformed files.

Check: What single discipline connects this chapter's date work with its JSON work, and why is a round-trip test a strong proof of understanding for conversion code?

Practical Task

Build a small event-log program called eventlog.py that stores events in a JSON file. Each event has a title, a due date (a calendar day), and a creation timestamp (an aware UTC datetime). The program should support three commands: add (read a title and a due date in YYYY-MM-DD format from the command line, reject invalid or ambiguous dates with a clear message, and store the event), list (load the file, convert the stored ISO strings back to date and datetime objects, and print events sorted by due date, showing the due date in a spelled-out format and how many days remain until it), and overdue (list only events whose due date is before today). Store the file with indented, sorted JSON, write via a temporary file and atomic replacement, treat a missing file as an empty log, and report a malformed file with its parse error instead of silently discarding it. Write tests for: adding and listing round-trips correctly, an invalid date being rejected, a leap-day due date, an event due today not counting as overdue, and a malformed file producing a clear error. Run the tests and fix any failures before considering the task done.

Chapter Questions

  1. Why should moments be stored in UTC and converted to local time only for display, and what goes wrong when local times are stored instead?
  2. Why does timedelta provide days and weeks but not months or years, and what rule must a program choose when adding a month to the 31st of January?
  3. How do loads() and dumps() differ from load() and dump(), and when should each pair be used?
  4. A JSON document parses successfully but your program still fails on it. What are the three stages of input checking, and which stage caught nothing in this case?
  5. Why must the parsing side of a JSON format know, field by field, whether a string field is a date, a datetime, or a plain string, and what happens if it guesses wrong?