Our reconciliation job was wrong every day for a week because London changed its clocks
Contents
Story from March that I promised a few people. Clocks go back again on the 25th, so this is the right week.
I work from Enugu for a London fintech. Nigeria is UTC+1 all year, no daylight saving. London is UTC+0 in winter and UTC+1 in summer. Our partner bank sends a settlement file per "London business day", midnight to midnight London time. Our reconciliation job builds the same window on our side and matches transactions.
The job had this, written years ago by someone in winter:
var london = time.FixedZone("London", 0) // "London is UTC" 🙃
In winter that is correct. On 29 March 2026 the UK moved to BST and London midnight became 23:00 UTC. Our window was still 00:00 to 00:00 UTC. The bank's window was 23:00 to 23:00 UTC. Every transaction between 23:00 and midnight UTC landed in different days on each side. Every morning: hundreds of mismatches. The auto-matcher "fixed" some of them by creating adjustment entries, which made it worse, and finance spent a week untangling refunds that were counted twice. Wahala.
The fix: name the zone, never the offset
import (
"time"
_ "time/tzdata" // embed the tz database, our image has none
)
var london = mustLoad("Europe/London")
func mustLoad(name string) *time.Location {
loc, err := time.LoadLocation(name)
if err != nil {
panic(err)
}
return loc
}
// settlementWindow returns [start, end) of the London business day containing t, in UTC.
func settlementWindow(t time.Time) (time.Time, time.Time) {
lt := t.In(london)
start := time.Date(lt.Year(), lt.Month(), lt.Day(), 0, 0, 0, 0, london)
end := start.AddDate(0, 0, 1)
return start.UTC(), end.UTC()
}
AddDate works on the wall clock in that location, so the day after a London midnight is the next London midnight, even when the day is 23 or 25 hours long.
That time/tzdata import: our container is built from scratch, no tz files. LoadLocation worked on my laptop and failed in production with unknown time zone Europe/London. The import embeds the database in the binary, about 450 KB. Worth every byte.
The test that now runs on every build
func TestSettlementWindowDST(t *testing.T) {
cases := map[string]struct {
at time.Time
want time.Duration
}{
"clocks forward": {time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC), 23 * time.Hour},
"clocks back": {time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC), 25 * time.Hour},
"normal day": {time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC), 24 * time.Hour},
}
for name, c := range cases {
start, end := settlementWindow(c.at)
if got := end.Sub(start); got != c.want {
t.Errorf("%s: got %v, want %v", name, got, c.want)
}
}
}
Rules we follow now: store everything in UTC. Convert only at the edges. When the business says "day", ask "whose day?" and write the zone name in the code. And test the two ugly days of the year, because they will come every year whether you test them or not 😂
Get the best of #ledger_lines weekly
One email each Monday with the most useful posts. No spam; unsubscribe in one click.
Comments
Sign in to join the conversation.
Sign in to comment