← thoughts

It's Always 05:53 in Kolkata

pythondjangotimezones

or, how deleting one dependency taught us more about time than we ever wanted to know


The setup: "it's basically a find-and-replace"

Every good horror story starts with someone saying it'll be quick.

Ours started with a dependency. We were upgrading our Django monolith from 4.2 to 5.2, and along the way we needed to get rid of pytz, the timezone library that's been quietly powering Python datetimes since before some of our engineers could walk. Python has shipped a perfectly good timezone library in its standard library since 3.9 (zoneinfo), pytz has been politely asking everyone to leave since 2020, and Django 5.x has fully moved on. So the task was clear:

# before
tz = pytz.timezone("Asia/Kolkata")
 
# after
tz = zoneinfo.ZoneInfo("Asia/Kolkata")

Eighty-seven of those across the codebase. A weekend of find-and-replace, a green test suite, a celebratory chai. How hard could it be?

Reader, it was hard. The swap itself stayed exactly as simple as it looks; the difficulty was in everything underneath it. Time is a lie we've all agreed to tell, and the moment you look at it closely, it looks back. What follows is a field guide to everything that bit us, everything we learned that we genuinely did not know before, and the handful of brilliant people on the internet who had already written down the answers we were about to discover the hard way.

Grab a chai. This one has a body count.


A field guide to why clocks lie

Before the war stories, a crash course, because half our bugs came from assumptions so obvious we never thought to question them. Almost every one of them appears, verbatim, in the two canonical lists every engineer should read once and re-read whenever they feel confident: "Falsehoods programmers believe about time" and its sequel "...about time zones." A tasting menu of things that are simply not true:

  • "Daylight Saving is one hour." Usually. Not always. And it's not even called the same thing twice. Americans "spring forward" into Daylight Saving Time; the British and Europeans switch to Summer Time (BST, CEST) and back to Winter Time. During WWII, Britain ran Double Summer Time, a full two hours ahead of GMT, so factory workers could get home before the blackout. The clocks have been a political instrument for a lot longer than they've been a software bug.

  • "DST makes the clock go forward." Meet Ireland, which runs its clocks on negative DST. By the letter of the Standard Time Act of 1968, Irish Standard Time is the summer hour (the legal baseline), and winter is the deviation, a one-hour subtraction. So when Dublin "falls back" in October, it's applying negative daylight saving. That is the literal encoding in the tz database, and it wrecked one of our assumptions (more on that later).

  • "Time zone abbreviations identify a zone." They do not. IST means Indian Standard Time (+05:30), and Irish Standard Time (+01:00), and Israel Standard Time (+02:00). Three continents, three offsets, one abbreviation. Never parse a zone from its abbreviation. Never.

  • "Offsets are whole hours, or at worst half-hours." India is +05:30. Nepal is +05:45, a quarter-hour offset that sits, with what we can only assume is deliberate serenity, exactly fifteen minutes ahead of its enormous neighbour. The Chatham Islands are +12:45. If your code assumes offsets are integers, a surprising number of countries are waiting to prove you wrong.

  • "Two dates that are one day apart are one day apart." Kiribati shifted its eastern islands across the International Date Line on the last day of 1994, inventing UTC+14 and becoming the first place on Earth to see each new day. Samoa did the opposite in 2011: to stop losing two business days a week to the gap between itself and Australia/New Zealand, it skipped December 30th entirely. If you had a Samoan birthday on the 30th that year, tough luck; the day did not happen.

Tom Scott summarised the whole genre in one Computerphile video in 2013: a man slowly realising, on camera, that time is a "twisty-turny thing" that leads programmers into madness. We rewatched it mid-migration. It hit different.

Every one of those bullets is a bug someone shipped, and several of them are bugs we shipped. Let's meet them.


The ghost of 05:53 (a.k.a. the fastest footgun in the west)

Here is a line of code that looks completely fine and is completely wrong:

datetime.datetime(2024, 6, 1, 9, 15, tzinfo=pytz.timezone("Asia/Kolkata"))

You'd expect that to be 09:15 in India, i.e. offset +05:30. What you actually get is offset +05:53: twenty-three minutes off, silently, forever.

Why? Because pytz doesn't work like a normal tzinfo. When you attach a pytz zone directly via tzinfo=, it reaches straight past your date for the first entry in the zone's history, which for Kolkata is the Local Mean Time the city kept back when "noon" meant "the sun is overhead" and standardised time zones hadn't been invented yet. Kolkata's solar mean time was about +05:53. So pytz cheerfully hands you a timestamp anchored to a sundial from the 1800s.

Asia-Kolkata UTC offset from 1800 to 2030, showing pytz snapping to the leftmost entry

Paul Ganssle documented this beautifully in a post titled, no notes, "pytz: The Fastest Footgun in the West." His example is New York, whose ghost offset is −04:56 (Manhattan's local mean time). Same trap, different city. His verdict on the library: it makes it "very easy to pytz incorrectly," even when you know exactly how it works. The correct pytz incantation is tz.localize(dt) (and tz.normalize() after any arithmetic, because pytz caches the stale offset). It's an entire ritual that exists purely to work around the footgun. zoneinfo has no footgun; you just attach it and it does the right thing.

We found the ghost of 05:53 in three separate places:

  1. In production, in booking/tasks.py, a .replace(tzinfo=pytz(...)) had quietly frozen every slot cursor to +05:53 instead of +05:30. It had been subtly wrong for years. zoneinfo fixed it, which is a lovely sentence until you realise "fixing" it also changes the output, which means it can't be a silent swap.

  2. In our test data, where dozens of assertions expected serialized timestamps like 2016-03-11 13:01:00+0553. Those +0553s were fossils: the LMT ghost, pinned into our expected values, teaching every new engineer the wrong offset by example.

  3. In the CGM subsystem, where a glucose event logged at 09:15 got bucketed by hour. Under the LMT ghost, "09:15" became "08:52" once it hit UTC, so the event landed in hour 8. Correctly interpreted as +05:30, it's hour 9. Our assertion said {8: 1}; the truth was {9: 1}. A twenty-three-minute lie, hiding inside a dictionary key.

The lesson we tattooed on the team's collective forearm: never build an aware datetime by handing a zone to tzinfo=. Not with pytz (you get the ghost), and honestly not as a reflex even with zoneinfo (where it's correct but where a .localize-shaped habit is safer). Attach zones with a tool that knows what day it is.


Two kinds of broken o'clock: gaps and folds

Twice a year, in most of the world, the clock lies on purpose.

  • In spring, it skips an hour. 02:00 to 03:00 simply doesn't happen. Any wall-clock time in that hour is a gap, an imaginary time that never existed. Ask for it and a strict library will refuse.

  • In autumn, it repeats an hour. 01:00 to 02:00 happens twice. Any wall-clock time in that hour is a fold: ambiguous, two real instants wearing the same face.

Python's answer to this is PEP 495, which added a single attribute to every datetime: fold. fold=0 means "the first time we saw 01:30"; fold=1 means "the second time." It is, per one blogger, a solution to "the hardest problem in computer science," and it is the quiet hero of the entire zoneinfo era. But it is not the same as pytz's old is_dst flag, and assuming they map one-to-one is how you get a very confident, very wrong migration.

Local time plotted against UTC, where a horizontal probe at 01:30 crosses the zone line twice

Here's where it drew blood. We had a helper that combined a user's date with midnight and attached their timezone. Fine for almost everyone: midnight is a boring, unambiguous time. Except Cairo and Beirut transition at 00:00, where New York and Berlin transition at 02:00. Their DST spring-forward happens at midnight, which means for those users, on that one day a year, midnight lands squarely in the gap, a time that does not exist. Our code caught AmbiguousTimeError (the fold) but not NonExistentTimeError (the gap), so a Cairo user on their spring-forward date got a clean HTTP 500. Once a year. Impossible to reproduce unless you happened to be in Egypt in April.

And then there's Ireland again. Remember its negative DST? The rule "at an ambiguous fall-back hour, pick standard time" normally means fold=1. But for a negative-DST zone like Dublin, standard time is the earlier reading, so it's fold=0. We got this backwards once and had to correct it, which is how we learned that "just use fold=1 for standard time" is itself a falsehood programmers believe about time.

One more trap worth flagging: make_aware(dt, some_zone) is not a safe bridge. With no explicit flag it behaves like pytz's localize(dt, is_dst=None), which raises at ambiguous times, where the old bare .localize(dt) would have silently shrugged and picked standard. Swap one for the other and you convert a silent success into a loud exception at exactly the two hours a year nobody's testing.


Bugs that only exist on someone else's computer

Some bugs are cowards. They wait until your laptop isn't looking.

The utc/UTC bug. Somewhere in the sweep, pytz.timezone("utc") became ZoneInfo("utc"). Looks fine. Passes every test on every developer's Mac. Ships to CI and explodes:

zoneinfo._common.ZoneInfoNotFoundError: 'No time zone found with key utc'

The IANA key is UTC, uppercase. pytz was case-insensitive and forgave "utc". ZoneInfo is case-sensitive. So why did it pass locally? Because macOS's filesystem is case-insensitive, so .../zoneinfo/utc happily resolved to the UTC file. Linux, where our CI and our production run, is case-sensitive and does no such favour. This is the purest form of "works on my machine": a bug that is literally invisible on a Mac and guaranteed on the server. We now validate every zone literal against zoneinfo.available_timezones() and never, ever trust a green local run to prove a zone key is real.

The country-code that upcased itself. pytz has a lookup, pytz.country_timezones, which quietly upcases your country code before looking it up, so "in", "IN", and None all behaved. It has no zoneinfo equivalent (the standard library simply doesn't ship a country→zone map), so we vendored our own 247-country dictionary. A plain dict, of course, does not upcase, so map["in"] returned None, and our function silently fell back to UTC. And an existing test passed "in" in lowercase, meaning the lowercase path was real, live, and load-bearing. One .upper() saved us from telling every lowercase-country-code user they lived in the Atlantic.

The one exception that became three. pytz folded every possible bad zone name into a single UnknownTimeZoneError. zoneinfo splits it into three: ZoneInfoNotFoundError for an unknown zone, ValueError for a malformed one ("", "../etc/passwd", a null byte), and OSError for one that's absurdly long. We catch this around a free-text, user-editable timezone field, so "catch the one pytz error" would have turned a junk value into a 500 instead of a graceful fallback. One exception became three the day we stopped using pytz, and nobody sends you a memo about that.


Your timezone database is a time capsule

Here's the one that reframes the whole project.

pytz ships data as well as code: a snapshot of the world's timezone rules, frozen at whatever version was pinned. Ours was pinned to 2022.2.1. zoneinfo, by contrast, reads the live tzdata on the system, which was years newer. So the migration came out offset-neutral for stable zones and a correction for zones whose rules had changed since 2022:

  • Egypt re-introduced DST in 2023. Under frozen-2022 pytz, Cairo was an hour wrong for half the year.

  • Mexico abolished most of its DST in 2022. Under frozen pytz, much of the country was an hour wrong the other half.

The tempting framing for the whole migration was "identical behaviour, cleaner imports." The honest one ran longer: "identical for stable zones, and quietly corrected for two dozen zones whose governments changed their minds." That's a sentence you have to say out loud to the people who read the reports, because a report that shifts by an hour looks like a bug even when it's a fix.

Why does timezone data drift at all? Because it's a living record of political whim, hand-maintained by volunteers. The tz database (the single source of truth behind pytz, zoneinfo, your phone, and basically every computer) has been curated since 1986, largely by Arthur David Olson and later Paul Eggert. In 2011 an astrology software company sued them, claiming the historical timezone data was lifted from a copyrighted atlas. The lawsuit (Astrolabe v. Olson) briefly took the database offline, the timekeeping backbone of modern computing nearly shut down by people who chart horoscopes, until the EFF stepped in, got it dismissed, and extracted a covenant never to sue again. Your calendar app survives at the pleasure of a volunteer mailing list that once had to lawyer up against astrologers. Update your tzdata.


Flip the sources last (and other things you learn by breaking prod)

The single most expensive lesson sits above every zone in this post: order of operations.

Deep in the codebase were a handful of "getter" functions (get_timezone_for_user() and friends) that returned a zone object. Change those to return zoneinfo and you're done, right? Except dozens of call sites downstream still called .localize() on the result, a pytz method that ZoneInfo does not have. Flip the source first, and every one of those sites detonates with:

AttributeError: 'zoneinfo.ZoneInfo' object has no attribute 'localize'

...in production, all at once. (We know because an early PR did exactly this.) The rule we extracted, and now recite: make the call sites timezone-library-agnostic first, and flip the sources last. The whole pytz-only surface is exactly three methods: .localize(), .normalize(), and .zone, so we hunt those down and neutralise them everywhere before the object underneath them changes shape. A related corollary for tests: a mock that fakes a timezone getter has to return the same kind of object the real getter now returns. Otherwise it either hides a real crash or invents a fake one.

And a final, humbling footnote. When we declared victory ("import pytz is zero across the whole repo!"), we were wrong, twice. A reviewer found a stray import pytz in a file called healthify/untitled: an ancient Python 2 scratch file (it has print statements; it cannot even parse under Python 3) with no .py extension, committed years ago and forgotten. It had no extension, so our *.py grep never saw it. And git grep, for reasons involving how git treats extensionless files, also silently skipped it. Two different searches, both blind to the same file. "Zero pytz" is a claim you should verify with a plain filesystem grep, not a clever one, because the clever ones have blind spots exactly where the cruft accumulates.


The reading list that actually saved us

We owe most of this to people who had already been bitten and had the decency to write it down. If you take one thing from this post, take this list:


The rules we live by now

If you're about to do this, or just want to stop shipping timezone bugs, here's everything above, distilled:

  1. Store instants, not wall-clock times. UTC or epoch for the moment something happened. Attach a human timezone only at the very edges, for display.

  2. Never build an aware datetime with tzinfo=<a named zone>. With pytz it's the LMT ghost; as a habit it's a trap. Use the library's proper "localize" path.

  3. Treat every timezone string as untrusted user input. It may be miscased (utc), it may be malformed, it may not exist. Normalize case, validate against the real zone list, and have a fallback that doesn't 500.

  4. Noon is your friend; midnight is a trap. If you must pick an arbitrary time of day for a date, pick one that can't fall in a DST gap. Noon never does. Midnight does, in Cairo, once a year.

  5. Test on the platform you deploy to. Your case-insensitive laptop will hide real bugs from you. Trust Linux CI, not a green run on a Mac.

  6. Flip the sources last. Make call sites library-agnostic before you change what the zone objects are. Hunt .localize / .normalize / .zone to zero first.

  7. Expect corrections, and announce them. Fresh tzdata fixes stale offsets. A fix that moves a report by an hour still looks like a bug to whoever reads the report. Tell them first.

  8. You will not out-clever the tz database. Do not hand-maintain a timezone list. Do not parse zones from abbreviations. Do not assume offsets are integers. The database has 40 years and a lawsuit's worth of edge cases you haven't thought of. Defer to it.


Coda

We set out to delete a dependency. We came back knowing that Ireland runs its clocks backwards, that Samoa once deleted a Friday, that our own booking system had been quietly living in 1854, and that somewhere on a mailing list a group of volunteers is, right now, encoding some parliament's latest decision about when the sun should be allowed to set.

import pytz is finally zero. The tests are green, on Linux this time. And every one of us now flinches, just slightly, whenever someone says a datetime task will be quick.

Time will still find a way. But at least now we know its names.


Written after the great pytzzoneinfo migration of 2026. Corrections, additions, and better jokes welcome. This codebase's relationship with time is ongoing.