Four posts in, your metrics are getting sharper — days-to-close, per-capita rates, reconciled counts. Then Maria asks for two numbers that look simple and aren't: average hours to close, and cost per closed request. One of them breaks on a Sunday in November. The other breaks on ten cents.

Assumes: Posts 1–4. Stdlib only: datetime, zoneinfo, decimal. No new installs.

Wednesday, 2:15 PM. Maria: "The ops review wants average hours-to-close, not days — and cost per closed request for November."

Tom's new export has what you asked for: created_at and closed_at as full ISO timestamps — 2026-11-01T00:30:00-04:00. And Dev sends the November sanitation budget: $4,250,000.

Two numbers. One hides a daylight-saving trap; the other hides a floating-point trap. Both are the same lesson: a number without its unit is a rumor.

Before you code: clarify the ask

You: "Hours from created to closed — in whose timezone?"

Maria: "Eastern, obviously."

You: "Eastern on which side of November 1st? The clocks went back that Sunday."

Maria: "...just make it right."

You: "And cost per closed request — November's recognized operating cost divided by requests closed during November, regardless of when they were created? Rounded how?"

Maria: "Yes — closed in November is what Finance reconciles against. And Finance uses half-up for this report."

Input: timestamp excerpt (3 November requests), November operating cost $4,250,000, 9 requests closed during November (Post 3's reconciled count)
Output: average hours-to-close + cost per closed request, both defensible
Deadline: tomorrow's ops review

"Just make it right" is doing a lot of work in that box — so you did the unglamorous part first: pinning down the denominator, the population, and the rounding policy before the arithmetic. Precision cannot rescue a badly defined metric.

The minimal concept

Three ideas:

Naive vs aware. A naive datetime has no timezone — datetime(2026, 11, 1, 1, 30) is 1:30 AM somewhere, maybe. An aware datetime carries its offset: 2026-11-01T01:30:00-04:00 is a single unambiguous instant. A timestamp without a timezone is a rumor.

UTC is the canonical store for instants; local time is a display choice. For event timestamps — "the request was created at this exact moment" — convert to UTC the moment they enter your system, do all arithmetic there, and convert to Eastern (or whatever Maria reads) only at the end. A huge class of timezone bugs comes from treating local wall-clock time as an elapsed-time clock. One nuance: a recurring civil schedule like "every Monday at 9 AM" is not an instant — don't flatten it to a fixed UTC time and call it done. Preserve the source timezone when the business meaning depends on local civil time.

Money is not a float. Floats are binary approximations — fine for measurements, wrong for money. Decimal represents decimal values such as cents exactly, and gives you explicit control over precision and rounding.

Fix it together: the Sunday the clocks went back

On November 1st, 2026, at 2:00 AM Eastern, clocks went back to 1:00 AM. The hour from 1:00 to 2:00 happened twice. Watch what that does to a naive subtraction:

from datetime import datetime, timezone

pairs = [
    ("2026-11-01T00:30:00-04:00", "2026-11-01T02:30:00-05:00"),  # across the fall-back
    ("2026-11-02T09:00:00-05:00", "2026-11-02T13:30:00-05:00"),
    ("2026-11-03T14:15:00-05:00", "2026-11-04T09:45:00-05:00"),
]
for created_s, closed_s in pairs:
    created = datetime.fromisoformat(created_s)
    closed = datetime.fromisoformat(closed_s)
    # hero pattern: instants -> UTC first, then arithmetic
    utc_h = ((closed.astimezone(timezone.utc) - created.astimezone(timezone.utc))
             .total_seconds() / 3600)
    naive_h = ((closed.replace(tzinfo=None) - created.replace(tzinfo=None))
               .total_seconds() / 3600)
    print(f"utc={utc_h}h  naive={naive_h}h")
utc=3.0h  naive=2.0h
utc=4.5h  naive=4.5h
utc=19.5h  naive=19.5h

The first request was actually open three hours: 00:30 at -04:00 is 04:30 UTC, and 02:30 at -05:00 is 07:30 UTC. In UTC the arithmetic is transparent — no DST to reason about. The UTC average is 9.0 hours; the naive average is 8.67. A twenty-minute error hiding in a single row, in a metric Maria will put on a slide.

Note the pattern, because it's the rule for the rest of your career: convert instants to UTC before elapsed-time arithmetic. Not "subtract aware datetimes and you're safe" — that's almost true, and almost-true is where the worst bugs live. Two datetimes sharing one ZoneInfo object subtract as wall-clock time: across the fall-back that gives 2 hours, not 3. Fixed offsets (what fromisoformat produces) convert properly — but the rule that always works, regardless of which timezone objects you're holding, is UTC first, then subtract.

The must-know: DST can make local times ambiguous. The must-do: require offset-aware event timestamps and normalize instants to UTC. The fold mechanics below are don't-memorize — read once, look up when needed. And the ambiguous hour itself — 1:30 AM happened twice that Sunday. Python marks which occurrence you mean:

from datetime import datetime, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")

first  = datetime(2026, 11, 1, 1, 30, tzinfo=ny)          # fold=0: the first 1:30 AM (EDT)
second = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=1)  # fold=1: the second 1:30 AM (EST)
print(first.utcoffset(), "->", first.astimezone(timezone.utc))
print(second.utcoffset(), "->", second.astimezone(timezone.utc))
-1 day, 20:00:00 -> 2026-11-01 05:30:00+00:00
-1 day, 19:00:00 -> 2026-11-01 06:30:00+00:00

Same wall-clock time, two different instants, one hour apart in UTC. When Tom's export gives you an offset (-04:00), the ambiguity is already resolved — another reason to demand offsets at the boundary and store UTC.

Money: ten cents is not 0.30000000000000004

>>> 0.1 + 0.2
0.30000000000000004
>>> from decimal import Decimal
>>> Decimal("0.1") + Decimal("0.2")
Decimal('0.3')

Floats store numbers in binary, and 0.1 has no exact binary form — so every float calculation carries a tiny error. Usually invisible; in money, eventually visible on an invoice. Decimal represents decimal values exactly and gives you explicit control over precision and rounding (note the nuance: Decimal("1") / Decimal("3") still can't be finite — exactness covers representation, not every result). Two rules prevent every money bug in this post:

Construct from strings, never from floats. Decimal(0.1) faithfully preserves the float's error — Decimal('0.1000000000000000055511151231257827021181583404541015625') — which is the opposite of what you wanted. Decimal("0.1") is exact.

Round explicitly, at the boundary. Cost per closed request for November:

from decimal import Decimal, ROUND_HALF_UP

cost = (Decimal("4250000") / Decimal("9")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(cost)          # 472222.22
print(4250000 / 9)   # 472222.22222222225  <- the float version, for the slide? no.

quantize(Decimal("0.01")) says "I want cents," and ROUND_HALF_UP says "halves round up" — Finance's stated policy for this report, encoded explicitly rather than assumed. (There is no universal financial rounding rule: accounting systems, currencies, jurisdictions, and payment processors differ. "Just give me dollars and cents" doesn't answer "which rounding policy?" — so you asked.) quantize defaults to banker's rounding, which is why the choice must be written down. The answer Maria gets is $472,222.22 per closed request: exact, rounded per policy, and reproducible.

You can compute $472,222.22 perfectly with Decimal — but if the numerator and denominator don't describe the same business population, it's still wrong. Precision cannot rescue a badly defined metric. That's why the definition got clarified before the arithmetic, and it's Post 3's principle wearing new clothes: don't trust a metric you can't reconcile.

Break it: time and money traps

The trapWhat happensThe fix
Mixing naive and awaredatetime(2026,11,1) < datetime(2026,11,1,tzinfo=timezone.utc) raises TypeError — the one loud failure in this table, and a gift.Make everything aware at the boundary: fromisoformat on offset-carrying strings, .astimezone(timezone.utc) immediately after.
Arithmetic in local timeSubtracting wall-clock times across a DST transition silently under- or over-counts by an hour — no error, wrong metric.Store and compute in UTC; convert to local only for display.
datetime.now()Returns naive local time — a rumor with your server's timezone baked in invisibly.datetime.now(timezone.utc). If you see a bare now() in a codebase, that's a finding.
strptime format mismatch"2026-11-01T00:30:00-04:00" against "%Y-%m-%d %H:%M:%S" raises ValueError — or worse, a wrong-but-parseable format silently misreads the data.Prefer fromisoformat for ISO data; validate the format at the boundary, not in the middle of arithmetic.
Float money0.1 + 0.2 = 0.30000000000000004; sums and divisions accumulate the error until it's visible.Decimal from strings for every money value, from load to report.
Decimal(0.1)Inherits the float's binary error into your "exact" arithmetic — exact wrongness.Decimal("0.1"). If the value ever touched a float, the damage is done; parse the original string.
Silent rounding modequantize() defaults to banker's rounding (ROUND_HALF_EVEN) — defensible, but Finance may expect something else, and the policy is a business rule you clarify, not an assumption you make.Ask, then state it: rounding=ROUND_HALF_UP here because Finance said so — and write the policy in the report.

Production-safe: units at the boundary

The whole post compresses to four boundary rules:

from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP

def parse_ts(s: str) -> datetime:
    """ISO timestamp in -> UTC instant out. Naive input is rejected, loudly."""
    dt = datetime.fromisoformat(s)
    if dt.tzinfo is None:
        raise ValueError(f"timestamp missing offset: {s!r}")
    return dt.astimezone(timezone.utc)

def parse_money(s: str) -> Decimal:
    """Money string in -> exact Decimal out. Floats never touch this path."""
    return Decimal(s.strip().replace("$", "").replace(",", ""))

created = parse_ts("2026-11-01T00:30:00-04:00")
closed  = parse_ts("2026-11-01T02:30:00-05:00")
print((closed - created).total_seconds() / 3600)  # 3.0

budget = parse_money("$4,250,000.00")
cost = (budget / Decimal("9")).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
print(cost)  # 472222.22

parse_ts refuses naive input instead of guessing — Post 2's boundary instinct, applied to time. parse_money strips the cosmetic formatting ($, commas) and goes straight to Decimal; the value never passes through a float — if the source gives you exact decimal text, don't convert it to float and then try to recover exactness later. One scope note: this parser implements the format contract for Dev's U.S.-formatted budget file. Don't generalize it into a universal currency parser — €4.250,00, (1,250.00), and USD 40.00 each need their own defined rules. Store the UTC instant and the exact decimal; format for humans only at the end.

Explain it to the customer

"Maria — November, average 9.0 hours to close (3-request excerpt; the full-intake number follows the same method). One thing worth knowing: a request spanning the November 1st clock change measures a full hour longer than wall-clock subtraction suggests — the pipeline converts to UTC before doing elapsed-time arithmetic, but it's why 'hours' needed its own pass. Cost per closed request: $472,222.22 (November operating cost ÷ 9 requests closed during November, halves rounded up per Finance's policy). Two caveats: the hours average is a 3-row excerpt, so treat it as direction — and the cost definition is the one we confirmed with Finance; if the denominator ever changes, say the word."

Notice the pattern by now: the answer, then the caveats, then the rerunnable method. The DST sentence is doing quiet work — it tells Maria why this post exists, so the number lands with its reasoning attached.

Must know

  • Naive vs aware datetimes — a timestamp without a timezone is a rumor
  • Store and compute in UTC; convert to local only for display
  • fromisoformat parses ISO timestamps including offsets
  • Decimal from strings for money — never from floats
  • quantize with an explicit rounding mode at the money boundary

Useful later

  • dateutil for messy real-world timestamp formats (when fromisoformat isn't enough)
  • Recurring schedules and cron semantics — "every Monday at 9 AM" has its own DST edge cases
  • Currency conversion and multi-currency accounting (when CityOps goes international)

Don't memorize this

  • fold mechanics — remember that the ambiguous hour exists, look up the handling
  • strftime format codes — look them up; remember to validate at the boundary
  • Every decimal rounding mode — remember to ask which policy Finance wants, then encode it

Where this lands in CityOps

parse_ts and parse_money join the boundary toolkit next to load_month's rename map and EXPECTED-columns check. In Milestone 2, cityops.db stores timestamps as UTC ISO strings and money as exact decimal text — the database holds instants and amounts, never rumors. The weekly report's hours-to-close column and the ops review's cost line both flow through these two functions, so when Finance asks "how did you compute this?" the answer is four lines long.

Post 4's principle was if you can't rerun it, you didn't clean it. Post 5's: a number without its unit is a rumor.

Field check

  1. A request is created at 2026-11-01T01:30:00-04:00 and closed at 2026-11-01T01:45:00-05:00. Naive subtraction says 15 minutes. What's the real elapsed time, and why?
  2. Your script stamps rows with datetime.now() while the export carries offsets. Name two things that can go wrong.
  3. Decimal(0.1) + Decimal(0.2) — what do you get, and what's the one-character-class fix?
  4. Maria needs a rough hallway estimate now and the published number tomorrow. What can differ between the two — and what must not?
  5. The budget file arrives as "$4,250,000.00" — dollar sign and commas. Your Decimal(...) chokes. Where do you handle it, and what must never happen in between?
What good answers look like

1. 75 minutes. The 1:00 AM hour happened twice: 01:30 at -04:00 is 05:30 UTC and 01:45 at -05:00 is 06:45 UTC — 1.25 hours apart. Naive subtraction compares wall-clock labels from two different offsets and silently drops the repeated hour. 2. First, comparing or subtracting a naive now() against aware export timestamps raises TypeError — loud, at least. Second, and worse: if both sides are naive (your stamp and a stripped export), the math runs fine and is silently wrong whenever the server's local timezone differs from the data's — a rumor baked into the pipeline. Fix: datetime.now(timezone.utc), always. 3. You get Decimal('0.3000000000000000166533453694') — the float's binary error, preserved exactly. The fix is the constructor's argument type: Decimal("0.1"), string in, exact out. 4. The underlying computation must not differ: same exact amount, same denominator, same Decimal path — Decimal("4250000") / Decimal("9") costs nothing extra, so there is no reason to keep a float version around. What can differ is presentation precision: the hallway answer can be verbally rounded ("about $472K"), while the published metric reports $472,222.22 with the rounding policy documented. Vary the presentation, never the arithmetic — the moment you keep two computation paths, they will disagree. Same judgment as Post 4's Excel question: match the rigor to the artifact's lifespan, not to your hurry. 5. At the boundary, in parse_money: strip $ and commas, then straight to Decimal. What must never happen is a float intermediate — parsing to float "temporarily" and converting to Decimal afterward preserves the float's error, so the poison must never enter the chain.