Timestamp fields look like a solved problem. Pick ISO 8601, return everything in UTC, document the format, and move on. Then customer complaints arrive. Dates appear one day off in monthly reports. Daylight saving time causes scheduled jobs to fire twice or skip a day. Naive timestamps get interpreted as local time by some libraries and as UTC by others. The right design choices for timestamp fields are unfashionably specific, and the patterns that look universal turn out to have exceptions that bite production deployments.
The three ISO 8601 formats and why the third one is dangerous
ISO 8601 admits three timestamp shapes that look similar but parse differently. The first is the UTC form with explicit Z suffix: 2026-05-29T14:30:00Z. This is unambiguous and the right default for API timestamps. The second is the offset form: 2026-05-29T10:30:00-04:00. This is also unambiguous but loses information about the original time zone name, which matters for recurring events that need to respect daylight saving rules. The third is the naive form without any offset or Z suffix: 2026-05-29T14:30:00. This is dangerous because parsers disagree about whether it means UTC or local time.
Python's datetime.fromisoformat treats the naive form as local time. JavaScript's Date constructor treats it as local time on some browsers and UTC on others depending on whether the string includes time and the JavaScript engine version. Java's Instant.parse rejects it entirely. Go's time.Parse requires explicit format strings and accepts whatever the format says. The naive form should never appear in API responses or be accepted in API requests. The right discipline is to reject it on input and never produce it on output.
UTC for storage, original time zone for recurrence
The standard recommendation is to store all timestamps as UTC. This is correct for most fields, including created_at, updated_at, and event timestamps. It is wrong for recurring schedules. A cron expression that fires "every weekday at 9 AM in New York" cannot be stored as UTC because the UTC offset changes twice a year due to daylight saving time. The correct storage is the local time plus the IANA time zone name (America/New_York), and the conversion to UTC happens at firing time using the current time zone rules.
Storing time zone names rather than offsets matters because time zone rules change. Countries adjust their daylight saving rules, change their permanent offset, or split into multiple time zones. The IANA tzdata database tracks these changes, and storing the IANA name lets the application pick up new rules without changing stored data. Storing the current offset freezes the historical rule and produces incorrect future firings when the rules change.
For our products, CronPing stores cron expressions plus an IANA time zone name per monitor, and the next-firing-time computation uses the current tzdata to resolve. FlagBit stores rollout-start timestamps as UTC because the rollout is an instant-in-time event with no recurring semantics. DocuMint stores invoice due dates as date-only fields with no time component because the business semantic is a calendar date in the seller's jurisdiction.
DATE vs DATETIME for date-only concepts
The case for using DATE rather than DATETIME for date-only concepts is underappreciated. An invoice due date is a calendar concept tied to a jurisdiction, not an instant in UTC. Representing it as DATETIME forces an arbitrary time component (typically midnight in some time zone) that introduces off-by-one bugs when the date crosses time zone boundaries. A subscription start date stored as 2026-05-29T00:00:00Z appears as 2026-05-28 to a customer in California unless every display layer remembers to convert at the right moment. Storing it as the DATE 2026-05-29 eliminates the conversion ambiguity entirely.
The discipline is: if the business semantic is "what day on the calendar" rather than "what instant in time," use DATE. If the business semantic is "what instant in time," use a UTC timestamp. The two concepts answer different questions and should not be conflated. DocuMint invoice due dates are calendar dates. CronPing monitor last-checked-at timestamps are UTC instants. FlagBit rollout-start timestamps are UTC instants. WebhookVault captured-at timestamps are UTC instants.
The webhook timestamp skew problem
Webhook deliveries include a timestamp in the signed payload header to prevent replay attacks. The receiver verifies that the timestamp is within a configurable window of current time (typically five minutes) and rejects deliveries outside the window. This works correctly only if the sender and receiver clocks are within a few seconds of each other. NTP synchronization is usually sufficient, but not always. Some receivers run on infrastructure where clock drift can reach minutes.
The right discipline is to use a generous window (five minutes is typical) with explicit per-receiver configuration for high-drift environments. WebhookVault uses a five-minute default with per-endpoint override and explicit error responses that include the timestamp delta. The error message helps the receiver-side debugging because it shows exactly how out-of-sync the clocks are, which lets the customer pick the right NTP fix without guesswork.
Microsecond precision and the truncation question
Postgres timestamps support microsecond precision. JSON serialization typically loses precision unless the encoder is explicit about format. JavaScript Date objects support only millisecond precision. Truncation at the API boundary is the rule that prevents surprises: timestamps are emitted with millisecond precision regardless of internal storage precision. The truncation is one-way (microsecond precision is lost on output), which matches what the consuming code can actually use.
For high-frequency event streams where microsecond precision matters, the right pattern is a separate sequence-number field that disambiguates events within the same millisecond rather than relying on timestamp precision. This works correctly across all clients regardless of language and serialization library.
Patterns that fail in production
Three patterns recur in production timestamp bugs. The first is accepting naive timestamps on input and parsing them as UTC, which produces silent off-by-many-hours errors for customers in non-UTC time zones. The fix is to reject naive timestamps on input with a clear error message. The second is storing recurring schedules in UTC, which produces correct firing times until daylight saving boundaries when jobs either fire twice or skip a day. The fix is to store local time plus IANA name. The third is mixing DATE and DATETIME concepts in the same schema, which produces off-by-one bugs when dates cross time zone boundaries. The fix is to pick the right type for the business semantic.
Our products converged on the same conventions: ISO 8601 with Z suffix for all timestamps, IANA time zone names for recurring schedules, DATE for calendar concepts, millisecond precision on output, and explicit rejection of naive timestamps on input. The conventions are unfashionably specific because the failure modes are unfashionably specific. Most timestamp bugs are not subtle race conditions; they are the boring cases the documentation skipped because they looked obvious.
Our products: DocuMint (PDF invoice generation API), CronPing (cron job monitoring with status pages), FlagBit (feature flags API for modern teams), and WebhookVault (webhook capture and replay) put these patterns into production.