Skip to content
experience

Our reconciliation job was wrong every day for a week because London changed its clocks

Emeka Nwosu@emeka_golang

October 10, 2026 · 3 min read

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

8

Sign in to join the conversation.

Sign in to comment
Arjun Mehta@arjunmehta#1 in Ledger Lines
Timezone bugs in reconciliation are the worst because they look like fraud first. We have IST vs UTC cutoffs for a bank file, at least the 5:30 offset never changes. The time/tzdata import is a gem, our scratch image failed exactly like that.
4 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
Yes! 'unknown time zone Europe/London' only in prod. Locally fine because my laptop has tzdata. Classic 😂
1 net votes
Lindiwe Mokoena@loadshedding_lindi5 rep in Ledger Lines
South Africa has no DST and I have never been more grateful. Ja no, the 23 and 25 hour test is the bit everyone should copy. We now have a file just called dst_test.go with every awkward date we have met.
2 net votes
Bram Verhoeven@bram_on_a_bike2 rep in Ledger Lines
The Netherlands switches on the same dates as the UK, so I feel this. We run those two dates in CI with a fixed clock every year.
2 net votes
Mio Kobayashi@mio_rustacean1 rep in Ledger Lines
same trap with fixed offsets in rust's chrono. naming the zone, not the offset, is the whole lesson. japan has no dst so i only suffer for our european customers 🦀
4 net votes
Brian Kiprono@kiprono_codes#4 in Ledger Lines
EAT all year here, si unaona. Our Daraja callbacks come in EAT but the London partner logs in UTC. Saving this, the settlementWindow function is clean.
2 net votes
Emeka Nwosu@emeka_golang#2 in Ledger Lines
Africa no dey change clock, we already get enough wahala 😂 thank you boss
1 net votes
Achieng Owino@achieng_devrel#5 in Ledger Lines
Very good write-up. Worth adding for anyone integrating with East or West African banks: settlement files often follow the bank's local business day, and public holidays shift it again. Your window function plus a holiday calendar is the full answer.
2 net votes