Handling Timezones and Dates Correctly

Of all the categories of bugs I have seen ship to production over a twenty-year career, the ones rooted in date and time handling are the most insidious. They...

Originally published onanselmfowel.com

Of all the categories of bugs I have seen ship to production over a twenty-year career, the ones rooted in date and time handling are the most insidious. They rarely show up in a demo. They pass the happy-path tests written by an engineer sitting in a single timezone, on a single machine, on a Tuesday afternoon. Then daylight saving time shifts, a customer in Sydney closes their books, a settlement file lands at 23:59, and suddenly a transaction is dated to the wrong day, an interest accrual is off by twenty-four hours, or a report shows a payment that has not technically happened yet.

Handling Timezones and Dates Correctly
Handling Timezones and Dates Correctly

In a regulated fintech environment, these are not cosmetic defects. A date error in a ledger is a reconciliation break. A timezone error in a statement is a compliance question. I have learned, mostly the hard way, that time is not a primitive you can treat casually. It deserves the same rigour you would apply to money. This post collects the practices my teams use to keep dates and times correct under pressure.

Store In UTC, Display In Local

The single most valuable rule I can offer is also the most repeated: persist every instant in UTC, and convert to a local timezone only at the moment you present it to a human. This is not because UTC is magical. It is because a system with one canonical reference frame is one you can reason about. When every timestamp in your database means the same thing, comparisons, sorting, and arithmetic all behave predictably.

The mistake I see most often is storing a local time without its offset, then trying to recover the meaning later. A row that says 2025-03-09 02:30:00 with no zone is ambiguous at best and impossible at worst, because in many regions that exact wall-clock time did not occur during the spring-forward transition. Once you have written an ambiguous timestamp, no amount of later cleverness fully recovers the truth. The discipline has to be enforced at the boundary, on the way in.

UTC in the database is not a performance optimisation or a stylistic preference. It is the difference between a system whose history you can trust and one whose history you have to apologise for.

Instants Are Not Wall-Clock Times

A surprising amount of confusion dissolves once a team internalises that there are two genuinely different concepts hiding under the word "time". An instant is a point on the universal timeline: the moment a payment was authorised, the same for everyone on the planet regardless of where they stand. A wall-clock time is what a clock on a particular wall reads: 9:00 in the morning, which is a different instant in London than it is in New York.

Most transactional events are instants. The moment money moved, the moment a session expired, the moment a webhook fired. These should be stored as UTC instants because the universal timeline is exactly what you care about. But some business concepts are genuinely wall-clock: a recurring billing run that must execute at 09:00 local time, or a cut-off that applies "by end of business day in the customer's region". Conflating the two is where the worst bugs live.

When a requirement says "send the statement at 8am", you must ask whose 8am. If the answer is the customer's local 8am, you cannot store a fixed UTC instant, because the correct instant changes twice a year as daylight saving shifts. You have to store the wall-clock intent plus the timezone, and compute the instant freshly each time.

Choosing The Right Type In .NET And SQL

In the .NET ecosystem I have a clear hierarchy of preferences. For an instant, I reach for DateTimeOffset, never a bare DateTime. A DateTime with DateTimeKind.Unspecified is a loaded gun: it will silently be interpreted as local or UTC depending on which method touches it next. DateTimeOffset at least carries an offset, removing the ambiguity at the point of capture.

For wall-clock-with-zone logic, where the calendar rules matter, I use the NodaTime library rather than fighting the built-in types. NodaTime's distinction between Instant, LocalDateTime, ZonedDateTime, and the IANA timezone database makes the modelling explicit, and explicit modelling is what prevents quiet mistakes. On the database side, prefer a type that stores the offset or is documented to be UTC, and never mix conventions across tables.

// Capturing an instant: always anchored to UTC
public sealed record PaymentAuthorised(
    Guid PaymentId,
    decimal Amount,
    DateTimeOffset AuthorisedAt); // stored as UTC in the DB

var authorised = new PaymentAuthorised(
    id, amount, DateTimeOffset.UtcNow);

// Converting for display, at the edge, using the
// customer's IANA zone rather than a fixed offset:
TimeZoneInfo zone =
    TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
DateTimeOffset local =
    TimeZoneInfo.ConvertTime(authorised.AuthorisedAt, zone);

// Anti-pattern to avoid entirely:
// DateTime t = DateTime.Now;  // ambiguous & machine-dependent
// if (t > deadline) { ... }   // compares apples to fog

Use Named Zones, Not Fixed Offsets

A timezone is not an offset. "UTC+1" is an offset; "Europe/Berlin" is a timezone. The difference is that a named zone carries the full history and future schedule of daylight saving transitions, while a fixed offset is frozen. If you store a customer's preference as "+01:00", you have thrown away the information needed to know that in July it should be "+02:00".

This is why I insist on the IANA timezone database (the so-called tz database) as the source of truth for zone identifiers. Names like America/New_York or Australia/Sydney encode the political and historical rules, including the fact that those rules change. Governments alter daylight saving policy with surprisingly little notice, and the tz database is updated to track them. Your job is to consume those updates, not to hardcode offsets that will quietly rot.

One practical caveat for Windows-heavy estates: the built-in Windows timezone identifiers differ from IANA names. Modern .NET can bridge the two on most platforms, but I prefer to standardise on IANA identifiers everywhere and treat the Windows names as an implementation detail to be converted at the edge.

The Two Days A Year That Break Everything

Daylight saving transitions create two pathological cases that ordinary testing never exercises. In spring, clocks jump forward, so a wall-clock time like 02:30 simply does not exist on that day. In autumn, clocks fall back, so 01:30 occurs twice. Any code that assumes every wall-clock time maps to exactly one instant is wrong on these days, and the failures are subtle.

Consider a scheduled job set to run at 02:00 local time. On the spring-forward day, depending on your scheduler, it may run at the wrong moment, run twice, or not run at all. On the autumn day, a "once a day at 01:30" job might fire twice. For a fintech, firing an accrual or a sweep twice is a real money event, not a logging curiosity.

  • Spring forward: a target wall-clock time may be skipped; decide explicitly whether to run at the gap boundary or skip the occurrence.
  • Fall back: a target wall-clock time may repeat; decide explicitly whether to use the first or second occurrence, and make sure the job is idempotent.
  • Midnight is not special: some zones have had transitions at midnight, so "start of day" is not guaranteed to exist either.
  • Offsets can be fractional: some zones run on 30- or 45-minute offsets, so never assume whole-hour arithmetic.

Business Days, Cut-Offs, And End Of Day

Financial systems run on the concept of a business day, and a business day is almost never aligned to UTC midnight. A "trade date" or "value date" is defined relative to a market's local calendar, with cut-off times that determine whether a transaction lands today or rolls to tomorrow. A payment submitted at 23:30 in one zone may belong to the next business day in the zone that actually processes it.

The correct approach is to make the cut-off rule explicit and to compute the business date from the instant plus the relevant zone, rather than reading the date component off a UTC timestamp. I have seen reconciliation breaks caused by nothing more than a report grouping payments by the UTC calendar date when the business defined the day in a local zone. The data was correct; the grouping was wrong; the auditors were unhappy.

Holidays compound this. A business-day calculation needs a holiday calendar, and holiday calendars are regional, changeable, and occasionally announced at short notice. Treat the calendar as configurable data, not as code, so that an operations team can update it without a deployment when a government declares a one-off bank holiday.

Leap Years, Leap Seconds, And Month Arithmetic

Beyond timezones, the calendar itself is full of traps. Adding one month to 31 January is undefined unless you decide what it means: there is no 31 February. Different libraries resolve this differently, some clamping to 28 February and some overflowing into March. For a billing system, "monthly on the 31st" is a specification gap, not a code detail, and it must be answered by the product, not guessed by a developer.

Leap years catch naive day-counting, especially around 29 February, where a "same day next year" calculation has no exact answer. Leap seconds are a rarer concern, and most systems sensibly ignore them by relying on UTC as exposed by the operating system, which smooths them out. I mention them mainly so that nobody tries to build their own leap-second handling; that is a problem best delegated to the platform.

The general principle is to lean on a well-tested calendar library for any arithmetic more complex than comparison. Hand-rolled date maths, with its modulo operations and magic constants, is where off-by-one errors breed.

Testing Time Without A Time Machine

You cannot wait until March to test your spring-forward behaviour, so time has to become an injectable dependency. I never allow production code to call DateTime.UtcNow or DateTimeOffset.Now directly in business logic. Instead, the current instant comes from an abstraction, often the IClock interface from NodaTime or .NET's own TimeProvider, so that tests can supply any instant, in any zone, at will.

With an injectable clock, the awkward dates become ordinary unit tests. You assert that a scheduled job fires correctly when the clock is set to the spring-forward boundary in Europe/Berlin, that a statement groups onto the right business day when submitted just before a local cut-off, and that month-end billing handles February. These tests are cheap to write once the clock is abstracted, and they document the intended behaviour better than any comment.

If your business logic reads the system clock directly, you have hardcoded "now" into your code. You cannot test the past or the future, which means the two days a year that matter most are exactly the days you can never reproduce on demand.

Time On The Wire

The boundary between systems is where conventions go to die. When two services exchange timestamps, any unstated assumption about format or zone becomes a latent defect. I require ISO 8601 with an explicit offset for every timestamp crossing a service boundary, so that 2025-06-24T14:30:00Z or 2025-06-24T16:30:00+02:00 is unambiguous to any reader, human or machine.

The failure mode here is a string like 2025-06-24 14:30:00 with no zone, which forces the receiver to guess. Different clients guess differently, and the bug surfaces only for users in particular regions, making it maddening to reproduce. Pin the format in your API contract, validate it on ingress, and reject anything that omits the offset rather than silently defaulting it. A loud rejection at the boundary is infinitely cheaper than a quiet corruption in the ledger.

The same discipline applies to logs, where a consistent UTC timestamp on every line lets you correlate events across services during an incident.

Anselm Fowel, CTO and fintech architect
Anselm Fowel — CTO & fintech architect

Conclusion

Handling time correctly is not glamorous, and it almost never appears on a roadmap as a feature. But it underpins every dated artefact a financial system produces, from a transaction record to a regulatory report, and the cost of getting it wrong is measured in reconciliation breaks, support tickets, and eroded trust. The good news is that the discipline is learnable and largely mechanical: store instants in UTC, distinguish instants from wall-clock times, use named IANA zones rather than frozen offsets, lean on tested libraries for arithmetic, inject your clock so you can test the hard days, and pin explicit formats at every boundary. Adopt those habits as standards rather than heroics, and time stops being the thing that quietly breaks your system at 2am twice a year.

Chat with us