The hour that doesn't exist: time zones, DST and the appointment that moved itself
A technician stood outside a locked building at seven in the morning because a recurring visit had been expanded into absolute instants six weeks before the clocks went back. The three kinds of "when" that don't convert into each other, why a recurrence rule is not a list of timestamps, what Odoo hands you over the API, and how to test a bug that only reproduces twice a year.
At 06:58 on the last Monday of October, a maintenance technician was standing outside a locked industrial building in East Flanders, phoning a site manager who was still at home. His planning app said the visit started at 07:00. The contract said 08:00. The customer had been expecting 08:00 for three years, because that is what the contract has always said.
Nothing was broken. Every row in that database was correct, every conversion was correct, and the app rendered exactly what it had been told. The bug had been committed six weeks earlier, in September, in one line of a script that turned "every Monday at 08:00" into fifty-two rows of timestamptz. On the day it ran, 08:00 in Ghent was 06:00Z. It was still 06:00Z in November, and in November 06:00Z is seven in the morning.
The clocks in the EU go back on the last Sunday of October — 25 October this year, about six weeks after this post goes up — and forward again on the last Sunday of March. That is Directive 2000/84/EC, and the proposal that was going to abolish the whole ritual in 2021 is still parked in the Council, so this is not a legacy problem you can wait out. Twice a year, every system we run for clients gets a live test of whether somebody understood what kind of thing a date is, and the failures are never in the timestamp library. They're in the modelling.
Three kinds of "when", and none of them converts into another
Almost every date bug we've dug out of a client system traces back to one type being used where another was meant. There are three, they answer different questions, and the conversion between them is lossy in a way that only shows up on two Sundays a year.
An instant. A point on the physical timeline, identical for everyone. When the invoice was sent, when the payment cleared, when the pod restarted, when the SMS left the gateway. Nobody in any office anywhere disagrees about when it happened; they only disagree about what to call it. This is timestamptz in Postgres, which despite the name stores no zone at all — it stores a UTC instant and converts on the way out.
A wall-clock time in a place. "Monday at 08:00 in Ghent." This is not an instant, and it cannot be turned into one without a calendar. It's a promise about what a clock on a wall will read, and the offset behind it changes twice a year without anybody editing the record. Appointments, opening hours, shift starts, the batch that runs "overnight", the reminder that goes out "the evening before". These need a local time plus an IANA zone — Europe/Brussels, never +02:00, because +02:00 is a snapshot of a rule and the rule is what matters.
A date. An invoice date, a VAT period, a birthday, a due date. It has no time and no zone and it is never midnight anything. The date on a Peppol invoice is a legal fact about a day, not an instant, and the moment somebody stores it as a timestamptz at midnight, a customer in a different zone sees the previous day on their invoice and your accountant has a very confusing morning. Postgres date, and it stays date all the way to the UI.
And then a fourth thing that isn't a time at all, which is where the technician's morning actually went wrong:
A rule. "Every Monday at 08:00 until the contract ends." A rule generates instants; it is not a set of them. The distinction is invisible for about seven months of the year.
If you can't say which of the four a column is, the column is wrong, and it will be wrong on exactly two Sundays a year.
Store the rule, not the results
The seeding script did something that feels obviously right: it read the contract, worked out the next fifty-two Mondays, converted each to an instant, and wrote them. One query to find today's visits, no recurrence logic in the app, fast and simple.
It's also a decision, taken silently, that the offset on the day the script ran is part of the appointment. Half those rows crossed a DST boundary and came out an hour early. The technician's app was right; the data had been wrong since September.
The fix is to store what the contract says and expand on read:
CREATE TABLE maintenance_visit_rule (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
contract_id bigint NOT NULL REFERENCES contract (id),
-- what a clock on the wall reads, and where that wall is
local_time time NOT NULL, -- 08:00
tz text NOT NULL, -- 'Europe/Brussels'
rrule text NOT NULL, -- 'FREQ=WEEKLY;BYDAY=MO'
starts_on date NOT NULL,
ends_on date
);
-- exceptions live next to the rule, never inside it
CREATE TABLE maintenance_visit_override (
rule_id bigint NOT NULL REFERENCES maintenance_visit_rule (id),
occurrence date NOT NULL, -- which one
moved_to timestamptz, -- or NULL for cancelled
PRIMARY KEY (rule_id, occurrence)
);This is the iCalendar model, and it is the iCalendar model for a reason: a recurring event is stored as a local start time plus a TZID plus a repetition rule, precisely so that "weekly at 08:00" keeps meaning 08:00 after the clocks move. Every calendar you have ever used works this way. Line-of-business software written in a hurry usually doesn't.
Two consequences worth knowing before you build on it.
Postgres already understands the difference, if you let it. Adding interval '1 day' to a timestamptz is calendar arithmetic evaluated in the session's TimeZone: it keeps the wall clock and moves the date. Adding interval '24 hours' is physics: it moves the instant by exactly 86,400 seconds. On 364 days a year they agree.
SET TimeZone = 'Europe/Brussels';
SELECT timestamptz '2026-10-24 08:00' + interval '1 day' AS calendar,
timestamptz '2026-10-24 08:00' + interval '24 hours' AS physics;
-- calendar | physics
-- 2026-10-25 08:00:00+01 | 2026-10-25 07:00:00+01generate_series inherits this, so a series stepped by interval '1 day' produces the right wall-clock slots across a switchover and one stepped by '24 hours' quietly drifts. Grouping has the same trap: date_trunc('day', ts) on a timestamptz truncates in the session zone, which in a container is UTC, which means your "daily revenue" chart cuts the day at 02:00 local. Postgres 16 and up take an explicit zone — date_trunc('day', ts, 'Europe/Brussels') — and it is worth the upgrade on that alone.
Querying a rule table is more work than querying rows. You need an index-friendly way to answer "what's happening this week", and expanding rules on every request doesn't give you one. What we do is keep a materialised horizon: a background job expands each rule to instants for the next, say, ninety days into a maintenance_visit table, and re-expands anything a DST boundary touches. The rule stays the source of truth, the expansion is a cache, and re-running it is idempotent because the primary key is (rule_id, occurrence_date). It's the same instinct as keeping the derived thing derivable — the seeding script's real sin wasn't caching, it was that nothing could regenerate the cache.
The hour that doesn't exist, and the one that happens twice
On 29 March 2026, Belgian clocks jumped from 02:00 to 03:00. There was no 02:30 that night. On 25 October they go from 03:00 back to 02:00, and 02:30 happens twice, an hour apart, with no way to tell the two apart from the wall clock alone.
Your booking form will happily offer both if nobody stops it, and every library handles them differently:
- Luxon gives you an invalid
DateTimefor the gap and silently picks the first (pre-transition) offset for the overlap. - Postgres shifts a non-existent local time forward into the gap and resolves an ambiguous one to the later offset.
- Python's
zoneinfodoes neither by default — it hands back a datetime withfold=0and lets you find out later. - JavaScript's
Date, parsing a local string, does whatever the platform does.
None of those is wrong; they're answering an unanswerable question with a default. The only robust move is to not need the default:
Never schedule anything meaningful between 01:00 and 03:00 local. This costs you nothing — the two hours of the day with the least business value are also the two that don't reliably exist — and it removes the entire class of bug from appointments, batch windows and reminders in one line of validation. When a client insists their nightly export must run at 02:30, we ask them why, and the answer has never once been a real constraint.
Make the overnight job idempotent anyway. A scheduled action at 02:30 local will run twice in October and not at all in March. Twice is only a problem if running twice is a problem, so make it not be: a processed_at column, an advisory lock, a natural key on the output. The cheapest fix for "the reminder went out twice" is a unique index, not a better cron expression.
Say what you mean for the overlap. If a real business rule lands in the repeated hour — a shift boundary, a rate change, an end-of-day cutoff — write the policy down (fold=1, "the second 02:30", "the later offset") in the code and in a test. A default that nobody chose is a default nobody can defend when it costs money.
Odoo stores UTC and shows you your own clock
Odoo gets this right internally and then hands the problem to whoever integrates with it. fields.Datetime is stored in the database as a naive UTC datetime; fields.Date is a plain date with no zone. The web client converts on display using res.users.tz, the time zone on the user's own record. Which means two employees can open the same delivery order and read two different times, both correct, and neither of them equal to what the database contains.
Inside a module this is a solved problem as long as you use the helper rather than datetime.now():
from odoo import fields, models
class MaintenanceVisit(models.Model):
_inherit = "maintenance.request"
def _local_day(self):
"""The calendar day this visit falls on, in the user's own zone."""
self.ensure_one()
# schedule_date is naive UTC in the DB; context_timestamp makes it aware
return fields.Datetime.context_timestamp(self, self.schedule_date).date()Three things bite everyone at least once.
ir.cron runs on UTC. nextcall is a UTC datetime, so "every day at 08:00" is really "every day at 08:00 UTC" — 09:00 in Brussels from March to October and 08:00 the rest of the year. A daily invoice run that clients think happens at nine suddenly happens at eight, and nobody reports it as a bug, they just start complaining that the numbers moved. If the job has to land at a local hour, schedule it hourly and return early unless the local hour matches.
The API gives you a naive string. A JSON-RPC read returns "2026-10-25 06:00:00" — space separator, no Z, no offset. Feed that to new Date() in a browser and it is interpreted as local time, so a Belgian user sees 06:00 instead of 07:00 and an hour vanishes on the way out of the ERP. This is the single most common integration bug we find, and it is exactly the reason we put a typed API layer in front of Odoo rather than letting six clients each rediscover it: one place normalises every datetime to 2026-10-25T06:00:00Z on the way out, and the ERP's own surface stops being everyone's problem.
Working hours have their own zone. resource.calendar carries a tz, and it is not inherited from the user reading the record. A calendar left on the default while the company operates in Brussels produces planning that is subtly wrong for field service routing — jobs packed against the wrong day boundary, travel time computed across an hour that isn't there.
The boundary is where it breaks
Every one of these bugs happens at a hand-off: database to server, server to browser, browser to API, API to the SMS gateway. The rules that survive contact with all of them are short.
Serialize instants with an offset, always. ISO 8601 with Z or an explicit offset, never a naked local string. If the wall clock is what matters, send the local time and the IANA zone as two fields and let the reader compose them. A single field cannot carry both meanings, and every attempt to make it do so ends with somebody parsing a string to guess which one it was.
Know what your parser assumes. new Date("2026-10-25T08:00") is local time. new Date("2026-10-25T08:00Z") is UTC. And new Date("2026-10-25") — a bare date — is parsed as UTC midnight by specification, which is how a birthday becomes the 24th for anybody west of Greenwich. Date-only values should never go near Date.
Format with an explicit zone, everywhere. On a server, "local" means whatever the container decided, which is a deployment property, not a user property.
const BUSINESS_TZ = "Europe/Brussels"
export function formatAppointment(
instant: Date,
timeZone: string = BUSINESS_TZ
): string {
return new Intl.DateTimeFormat("nl-BE", {
dateStyle: "full",
timeStyle: "short",
timeZone, // without this, Node formats in the container's zone
}).format(instant)
}
/** The visitor's own zone, for "times shown in ..." disclosure. */
export function browserTimeZone(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone
}This matters more than it used to. When the fetch moved to the server, so did the formatting, and a server-rendered date formatted without an explicit timeZone is rendered in UTC — then re-rendered on the client in the visitor's zone, which React reports as a hydration mismatch if you're lucky and as a silently wrong time if you're not. Pass the zone in, or render the ISO string and format it in a client component. Don't let it be ambient.
And say which zone you're showing. A booking confirmation that reads "Monday 26 October, 08:00" is ambiguous to a customer sitting in Lisbon. "08:00 (Europe/Brussels)" costs eleven characters and eliminates a support ticket. For SMS confirmations — 160 characters, no room for nuance — we settle the convention with the client up front: the appointment is always stated in the site's local time, because that's the clock the customer will be standing next to.
Containers have no time zone, and that's the good news
A minimal container image has no /etc/localtime, so TZ is unset and everything runs in UTC. That is the correct configuration and you should keep it. The temptation, when a log line looks an hour off, is to set TZ=Europe/Brussels on the deployment — which fixes the log and quietly changes the behaviour of every unqualified date operation in the process, including the ones in your dependencies.
Two things that do need attention:
Kubernetes CronJob has a timeZone field, and you should use it. It went stable in 1.27. Without it, the schedule is interpreted in the zone of the kube-controller-manager, which is UTC on every cluster we run and which nobody has ever documented to a client. With timeZone: Europe/Brussels, a 0 8 * * * schedule fires at eight local all year — and, note, skips or repeats around the transitions exactly like everything else. Same rule as before: make the job idempotent.
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-export
spec:
schedule: "0 8 * * *"
timeZone: "Europe/Brussels" # stable since k8s 1.27; UTC without it
concurrencyPolicy: ForbidShip tzdata, and keep shipping it. The IANA database changes several times a year because governments change their minds — a country moving its transition dates with a few weeks' notice is routine. An image built on a base that has no tzdata, or one pinned two years ago, will confidently apply a rule that no longer exists. It belongs in the runtime stage of your image, and it rides along with the base-image updates you're already automating. This is one of the few "just rebuild regularly" arguments that is genuinely about correctness rather than CVEs.
Testing a bug that reproduces twice a year
The reason these survive code review is that the test suite runs in one zone, on a machine set to that zone, on a day that isn't a Sunday in late October. Three cheap habits catch almost all of it.
Run the suite under a hostile TZ in CI. Not a second Belgian zone — one that disagrees about everything. TZ=Pacific/Kiritimati is UTC+14 and has no DST; TZ=America/St_Johns is UTC−03:30 and does. Anything that breaks under those was reading an ambient zone it shouldn't have been.
// CI runs the suite twice: once at TZ=UTC, once at TZ=Pacific/Kiritimati.
// Every failure in the second run is a place that assumed the server's clock.
process.env.TZ ??= "UTC"Freeze the clock on the transitions, not on today. The fixtures worth having are the four boundaries: the Saturday before, the gap (2026-03-29T02:30 local), the overlap (2026-10-25T02:30 local, both of them) and the Monday after. Assert on the rendered wall clock, not on the instant — the whole point is that the instant is allowed to move.
Test Odoo with a user whose tz isn't yours. A module test that creates its user without a zone inherits the server's and tests nothing. Setting tz to something absurd in the fixture turns a whole class of latent bug into a red bar, and it fits neatly into the CI gates you already run for modules.
One more habit that isn't a test: put the zone in the incident log. When a client reports "it happened at 9", the first question is whose nine. Logs in UTC, UI in local, both labelled — the dashboards already do this, and it costs nothing to make the application agree with them.
Where this is going
Temporal, the replacement for JavaScript's Date, encodes exactly the distinction this post is about: Temporal.Instant for a moment, Temporal.ZonedDateTime for a wall clock in a place, Temporal.PlainDate for a date. It's shipping in Firefox and in progress elsewhere, with a usable polyfill in the meantime — and the reason to care isn't the API, it's that the type system will finally stop you assigning one of the three to another. Until then a library and a convention do the same job, as long as everybody on the project knows which is which.
The short version
A date column is a modelling decision, not a storage decision. Four different things get written into the same timestamp type and only one of them survives a DST boundary intact:
- Instants —
timestamptz, serialized with an offset, formatted with an explicit zone. - Wall-clock times — a local time plus an IANA zone, two fields, never an offset.
- Dates —
date, all the way to the UI, never touched by aDateconstructor. - Rules — stored as rules and expanded on read, with the expansion treated as a rebuildable cache.
Then a handful of habits: nothing scheduled between 01:00 and 03:00; every recurring job idempotent; timeZone set on your CronJobs and tzdata fresh in your images; the suite run under a zone that disagrees with yours; and the zone printed next to the time everywhere a human reads it.
None of this is difficult, and all of it is cheaper than the alternative, which is finding out in production on a Monday morning while somebody stands outside a locked building. We build the planning and portal software that these appointments live in, the Odoo behind it that holds the contract, and the SMS platform that tells the customer when to expect somebody — which means we get all three versions of this bug, and have learned to check for it in September rather than on the 25th of October.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.