Je gaat een tijdzonebug shippen. Die komt door code review. Die slaagt voor al je tests. Die werkt perfect op jouw machine. En dan dient een gebruiker in een andere tijdzone een ticket in dat nergens op slaat. Tot je er twee uur naar staart en beseft: hun "vandaag" is niet jouw "vandaag."
Dit is geen beginnersfout. Ervaren developers die al jaren productiesystemen draaien, lopen keer op keer tegen tijdzones aan. Niet uit slordigheid, maar omdat tijdzonebugs zich beter verstoppen dan welke andere bug dan ook.
Waarom tijdzonebugs overleven
De meeste bugs kondigen zich aan. Een null pointer crasht direct. Een ontbrekende import faalt bij opstarten. Een kapotte query geeft nul resultaten.
Tijdzonebugs zijn anders. Ze geven bijna correcte resultaten. Een bestelling staat op de verkeerde dag. Een rapport wijkt er eentje af. Een geplande betaling gaat te vroeg af. De data ziet er aannemelijk genoeg uit om niet op te vallen. Tot de cijfers weken later niet meer kloppen.
Het tweede probleem: je hele team zit waarschijnlijk in dezelfde tijdzone. Je laptop, je CI-server, je staging-database. Zolang alle klokken gelijklopen, zijn tijdzonebugs onzichtbaar. De code klopt in jouw tijdzone. Maar niet in die van iemand anders.
De bugs waar je je aan brandt
Bug 1: De middernachtgrens
Dit is de meestvoorkomende tijdzonebug in productie. Een gebruiker doet iets rond middernacht, en het systeem boekt het op de verkeerde datum.
# Een gebruiker in Amsterdam plaatst een bestelling om 23:30 CET (15 maart)
# Je server draait in UTC. Daar is het al 16 maart.
order = Order.create(
user_id=user.id,
placed_at=datetime.now(), # UTC: 2026-03-16 22:30:00
)
# Het dagrapport voor 15 maart?
# Deze bestelling staat er niet bij. Die "vond plaats" op 16 maart.
# De gebruiker ziet "15 maart" in de UI. Finance ziet "16 maart."
# Niemand merkt het tot de maandafsluiting niet klopt. De oorzaak: datetime.now() zonder tijdzone. Het geeft terug wat de serverklok zegt. Draait je server in UTC en zit je gebruiker in CET, dan belandt elke actie tussen 00:00 en 01:00 CET op "morgen" in je database. Je verkooprapportages, je analytics, je facturatie: alles net iets verkeerd. Altijd dezelfde kant op. Niemand merkt het, want de totalen liggen dicht genoeg bij.
Bug 2: De betaling die te vroeg afgaat
Datumvelden zonder tijdzone zijn een valstrik. Een gebruiker kiest "15 maart" in een kalenderwidget en bedoelt 15 maart in hun tijdzone. Maar een DATE-kolom heeft geen tijdzone. Je cron job leest het als UTC. Voor iedereen ten westen van Greenwich valt hun "15 maart" een dag te vroeg.
# Gebruiker plant een betaling op "15 maart"
# Ze bedoelen 15 maart in hun tijdzone: America/New_York
payment = ScheduledPayment.create(
amount=499.00,
scheduled_date=date(2026, 3, 15), # Maar wiens 15 maart?
)
# Cron job draait om middernacht UTC.
# Voor de gebruiker in New York is het nog 19:00 op 14 maart.
# De betaling gaat een dag te vroeg af.
# Afschrijving voor betaaldag. Kaart geweigerd. Klant weg. Dit is de bug die echt geld kost. Een betaalverwerker die een dag te vroeg afschrijft omdat de geplande datum in de verkeerde tijdzone werd gelezen. Een webshop die een flash sale start om middernacht UTC in plaats van middernacht in de tijdzone van de klant. Een abonnement dat verlengt voordat de factuurperiode voorbij is.
Bug 3: De test die liegt
Tijdzonebugs overleven niet alleen je tests. Je tests dekken ze juist toe.
# Deze test slaagt op jouw machine in Amsterdam
def test_order_shows_correct_date():
order = create_order(placed_at=datetime(2026, 3, 15, 23, 30))
assert order.display_date == "15 maart 2026" # ✓ Slaagt
# Dezelfde test op de CI-server in us-east-1 (UTC-5 / UTC-4)
# Slaagt ook. Om een andere reden.
# Productieserver in UTC:
# order.placed_at = 2026-03-15 23:30:00 UTC
# Gebruiker in CET ziet: "16 maart 2026" (CET = UTC+1)
# De test heeft dit nooit opgepikt omdat die nooit cross-timezone draaide. De test slaagt omdat de tijdzone van de testomgeving dezelfde aanname maakt als de developer. CI slaagt omdat het in dezelfde regio draait. Niemand schrijft een test die zegt "maak een bestelling aan om 23:30 CET en check dat een CET-gebruiker 15 maart ziet en een UTC-gebruiker 16 maart." Die test bestaat niet, want de developer dacht er niet aan. De code werkte, de test bevestigde het. Klaar.
Bug 4: Zomertijd
Is de middernachtgrens de meestvoorkomende tijdzonebug, dan is zomertijd de wreedste. Het slaat twee keer per jaar toe, op verschillende datums per land, en het breekt een van de meest vanzelfsprekende aannames in programmeren: dat een dag 24 uur heeft.
# Zomertijd: de stille vernietiger
from datetime import datetime, timedelta
# Amsterdam, laatste zondag van maart 2026: klok gaat van 02:00 naar 03:00
# Je plant iets "over 24 uur"
start = datetime(2026, 3, 29, 10, 0) # 10:00 CET (UTC+1)
next_day = start + timedelta(hours=24) # 10:00 de volgende dag... toch?
# Fout. Op 29 maart schakelt Amsterdam over naar CEST (UTC+2).
# 24 uur later in UTC klopt, maar in lokale tijd
# ziet de gebruiker 11:00 in plaats van 10:00.
# Je "dagelijkse 10:00-herinnering" gaat nu om 11:00 af.
# Of erger: het omgekeerde in de herfst. Dan gaat die twee keer af. Zomertijd breekt terugkerende events, geplande taken, intervalberekeningen, en alles wat uren optelt of aftrekt om van dag te wisselen. De lenteovergang levert een dag van 23 uur op. De herfstovergang eentje van 25 uur. Als je code 24 aanneemt, loopt alles scheef.
Het venijnige: deze bugs duiken maar twee keer per jaar op, op specifieke datums, voor specifieke tijdzones. Je kunt je app een half jaar draaien zonder er een te zien. Dan, op een zondag in maart, draait je dagrapport twee keer of helemaal niet, en ben je de ochtend kwijt aan uitzoeken waarom.
De oplossing is geen library
Elk tijdzone-artikel komt uiteindelijk uit bij een library. Gebruik pytz. Gebruik date-fns-tz. Gebruik ZoneInfo. En ja, gebruik goede tijdzone-libraries. Maar een library helpt pas als je weet waar je hem nodig hebt.
De echte oplossing is een set regels die je hele team volgt. Gedocumenteerd, afgedwongen en getest. Dit zijn ze.
Regel 1: Sla UTC op. Altijd.
Elke timestamp in je database hoort UTC te zijn. Geen uitzonderingen. Gebruik TIMESTAMPTZ in PostgreSQL, niet TIMESTAMP.
-- FOUT: lokale tijd opslaan zonder tijdzone
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
placed_at TIMESTAMP NOT NULL -- Geen tijdzone. Welke dan?
);
-- GOED: altijd UTC opslaan met tijdzone-type
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
placed_at TIMESTAMPTZ NOT NULL -- PostgreSQL slaat intern op als UTC
);
-- Bij inserten: stuur altijd UTC
INSERT INTO orders (placed_at) VALUES ('2026-03-15T22:30:00Z');
-- Bij opvragen voor weergave: converteer op querytijd
SELECT placed_at AT TIME ZONE 'Europe/Amsterdam' AS local_time
FROM orders
WHERE id = 1; PostgreSQL's TIMESTAMPTZ slaat alles intern op in UTC en converteert bij output op basis van de sessietijdzone. Correcte conversie zonder applicatielogica. De database regelt het.
Het kernpunt: TIMESTAMP (zonder tijdzone) betekent niet "de tijdzone doet er niet toe." Het betekent "de tijdzone is onbekend." Dat is erger. Een onbekende tijdzone is een bug die wacht op de juiste gebruiker om af te gaan.
Regel 2: Naive datetimes zijn bugs
In Python heet een datetime zonder tijdzone-info "naive." Zo'n object heeft geen idee welke tijdzone het voorstelt. UTC? Lokale tijd? De tijdzone van de server? Je kunt het niet aflezen.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
# FOUT: naive datetime (geen tijdzone-info)
now_naive = datetime.now()
# >>> datetime(2026, 3, 15, 14, 30, 0)
# Dit kan ELKE tijdzone zijn. Python weet het niet. Jij ook niet.
# Werkt op je laptop. Breekt op de server.
# GOED: timezone-aware datetime
now_utc = datetime.now(timezone.utc)
# >>> datetime(2026, 3, 15, 13, 30, 0, tzinfo=datetime.timezone.utc)
# Converteer voor weergave in de tijdzone van de gebruiker
user_tz = ZoneInfo("Europe/Amsterdam")
now_local = now_utc.astimezone(user_tz)
# >>> datetime(2026, 3, 15, 14, 30, 0, tzinfo=ZoneInfo("Europe/Amsterdam")) Hetzelfde geldt in elke taal. JavaScript's new Date() is timezone-aware (systeemtijdzone), maar dat helpt niet als je het serialiseert zonder offset. TypeScript met date-fns of luxon biedt expliciete tijdzoneafhandeling. Gebruik het.
De regel: heeft een datetime geen tijdzone-informatie? Dan is het een bug. Geen stijlkeuze. Een bug.
Regel 3: Converteer aan de randen
Je API is de grens tussen "overal UTC" (backend) en "lokale tijd voor de gebruiker" (frontend). Dat contract moet waterdicht zijn.
// API-response: altijd ISO 8601 met UTC-offset
{
"order": {
"id": 12345,
"placed_at": "2026-03-15T22:30:00Z", // Altijd UTC
"amount": 499.00
}
}
// Frontend: converteer naar de lokale tijdzone van de gebruiker
const placedAt = new Date(order.placed_at);
const formatted = placedAt.toLocaleDateString('nl-NL', {
timeZone: 'Europe/Amsterdam',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
// "15 maart 2026 23:30" De backend stuurt UTC. De frontend rekent om voor weergave. De gebruiker ziet nooit "Z" of "+00:00." Die ziet gewoon de eigen lokale tijd. Dit is het enige punt waar je converteert. De rest van je systeem is UTC.
Regel 4: Datumgrenzen hebben een tijdzone nodig
Vraagt een gebruiker om "de bestellingen van vandaag," dan is dat een tijdzone-afhankelijke vraag. "Vandaag" begint om middernacht in de tijdzone van de gebruiker. Niet middernacht UTC.
# FOUT: filteren op datum met naive datumgrenzen
orders_today = db.query(Order).filter(
Order.placed_at >= date.today(),
Order.placed_at < date.today() + timedelta(days=1)
).all()
# Wiens "vandaag"? Die van de server. Niet die van de gebruiker.
# GOED: converteer de datumgrenzen van de gebruiker naar UTC
user_tz = ZoneInfo("Europe/Amsterdam")
user_today_start = datetime.combine(
date.today(),
time.min,
tzinfo=user_tz
).astimezone(timezone.utc)
user_today_end = user_today_start + timedelta(days=1)
orders_today = db.query(Order).filter(
Order.placed_at >= user_today_start,
Order.placed_at < user_today_end
).all() Dit is de fix voor Bug 1. In plaats van de serverdatum als grens te gebruiken, bereken je de grenzen vanuit de tijdzone van de gebruiker, reken je ze om naar UTC, en query je in UTC. De database bevat UTC-timestamps. De querygrenzen zijn UTC. Maar ze vertegenwoordigen de lokale dag van de gebruiker.
Regel 5: Cron jobs zijn niet tijdzonevrij
Een cron job die "om middernacht" draait is betekenisloos als je niet zegt wiens middernacht. Levert je cron job gebruikersgerichte output (dagrapporten, geplande notificaties, factuurcycli), dan moet die rekening houden met de tijdzone van de gebruiker.
# FOUT: cron job draait om middernacht UTC voor "dagelijkse" taken
# 0 0 * * * python run_daily_report.py
# Voor gebruikers in UTC+1 eindigt "gisteren" om 23:00 UTC.
# Voor gebruikers in UTC-8 eindigt "gisteren" pas om 08:00 UTC.
# Een middernacht-UTC cron pakt geen van beide goed.
# GOED: draai de job nadat de laatste tijdzone de dag afsluit
# Baker Island (UTC-12) is de laatste die middernacht bereikt.
# Dus "gisteren" is pas overal voorbij om 12:00 UTC.
# Beter: draai per tijdzone voor gebruikersgerichte rapporten
from zoneinfo import ZoneInfo
from datetime import datetime, timezone
def get_yesterday_range(tz_name: str):
tz = ZoneInfo(tz_name)
now_local = datetime.now(timezone.utc).astimezone(tz)
yesterday_local = now_local.date() - timedelta(days=1)
start = datetime.combine(yesterday_local, time.min, tzinfo=tz)
end = datetime.combine(yesterday_local, time.max, tzinfo=tz)
return start.astimezone(timezone.utc), end.astimezone(timezone.utc) Bij een wereldwijde app betekent dat vaak: de job meerdere keren draaien, per tijdzonecohort. Bij een eenvoudigere setup waar iedereen in dezelfde tijdzone zit, stel je de cron expliciet in op die tijdzone en documenteer je het.
Regel 6: Test in een vijandige tijdzone
Zet de tijdzone van je CI-server op iets dat ver van je team afligt. Pacific/Auckland (UTC+12/+13) werkt goed: het loopt voor op bijna elke ontwikkeltijdzone, dus datumgrenzen wijken af voor vrijwel iedereen.
Voeg testcases toe voor:
- Middernachtkruising: een event om 23:30 in een tijdzone dat op een andere datum valt in UTC
- Zomer-/wintertijdovergangen: zowel lente (uur kwijt) als herfst (uur erbij)
- Datumvelden: controleer dat "15 maart" in UTC-8 en "15 maart" in UTC+9 verschillende UTC-ranges opleveren
- Jaarwisseling: 31 december om 23:30 in een positieve-offset tijdzone (in UTC al 1 januari)
Bestaan deze testcases niet in je codebase, dan is je tijdzoneafhandeling ongetest. Wat je coverage-tool ook zegt.
De ARCHITECTURE.md-sectie
Regels die alleen in hoofden bestaan, worden vergeten. Regels die in documentatie staan, worden gevolgd. Hier is een compleet tijdzonebeleid dat je in je ARCHITECTURE.md kunt plakken. Het dekt opslag, API-contracten, applicatieregels, zomer-/wintertijd en testvereisten.
## Tijdzonebeleid
### Opslag
- Alle timestamps als `TIMESTAMPTZ` in PostgreSQL (intern UTC)
- Applicatiecode gebruikt uitsluitend timezone-aware datetimes
- Naive datetimes zijn een bug. Linters dwingen dit af.
### API-contract
- Alle API-responses in ISO 8601 met UTC-offset: `2026-03-15T22:30:00Z`
- Alle API-requests accepteren ISO 8601. Zonder offset? Weiger het request.
Neem nooit stilzwijgend UTC aan.
- De `Accept-Timezone` header of het gebruikersprofiel bevat de IANA-tijdzone
(bijv. `Europe/Amsterdam`). Leid de tijdzone nooit af van IP of browser-offset.
### Applicatieregels
- `datetime.now()` is verboden. Gebruik `datetime.now(timezone.utc)`.
- `timedelta(days=1)` is verboden voor "volgende dag"-berekeningen met lokale tijd.
Gebruik `ZoneInfo`, bereken de volgende kalenderdag, en converteer terug naar UTC.
- Cron jobs met gebruikersgerichte datumoutput houden rekening met de tijdzone van
de gebruiker. "Het rapport van gisteren" = gisteren in de tijdzone van de gebruiker.
### Datumgrenzen
- "Vandaag" = vandaag in de tijdzone van de gebruiker, omgerekend naar UTC voor queries.
- Datum-velden (`DATE` kolommen) zijn een kalenderdatum in de tijdzone van de gebruiker.
Sla de IANA-tijdzone ernaast op: `scheduled_date DATE`, `scheduled_tz TEXT`.
- Terugkerende events: sla lokale tijd + tijdzone op, geen UTC-timestamp.
### Zomer-/wintertijdovergangen
- Tel nooit uren op of af om tussen dagen te wisselen. Gebruik kalenderrekenkunde
via `ZoneInfo` of equivalent. Dat handelt zomer-/wintertijd automatisch af.
- Test met overgangsdatums: laatste zondag van maart en oktober (EU),
tweede zondag van maart en eerste zondag van november (VS).
### Testen
- CI-tijdzone staat op een niet-UTC-zone (bijv. `Pacific/Auckland`, UTC+12/+13)
om aannames over de servertijdzone te vangen.
- Elke datumgrens-query heeft een testcase die middernacht kruist in een
niet-UTC-tijdzone.
- Overgangsdatums voor zomer-/wintertijd zitten in de test fixtures.
### Frontend
- Toon tijden in de tijdzone van de gebruiker. Nooit rauwe UTC aan eindgebruikers.
- Gebruik `Intl.DateTimeFormat` of een library die IANA-tijdzones ondersteunt.
- Relatieve tijden ("2 uur geleden") zijn tijdzoneveilig. Absolute tijden
("15 maart") vereisen tijdzonecontext. Deze sectie doet drie dingen. Ten eerste voorkomt het nieuwe tijdzonebugs doordat de regels zwart op wit staan. Ten tweede geeft het AI-codeerassistenten de context om tijdzone-correcte code te genereren. Ten derde heeft je team een referentie als ze tegen een randgeval aanlopen en moeten beslissen hoe ze het aanpakken.
De sectie is klein. Achttien regels, een paar regels toelichting. Maar die achttien regels voorkomen een hele categorie bugs die al decennialang stilletjes data verpesten in productiesystemen.
De kern
Tijdzones zijn geen technisch probleem dat je een keer oplost. Het is een beleidsafspraak die je team maakt en continu handhaaft. De bugs komen niet door slechte developers. Ze komen door ontbrekende regels.
Sla UTC op. Verbied naive datetimes. Converteer aan de randen. Maak datumgrenzen tijdzonebewust. Test in een vijandige tijdzone. Schrijf het op in je ARCHITECTURE.md.
Je volgende tijdzonebug zit al in je codebase. Die is vorige week door je tests geglipt. Die duikt op wanneer een klant in een tijdzone die je nooit hebt getest een ticket indient dat je niet kunt reproduceren. Tenzij je het nu voorkomt.