A 30+ year old system: dozens of statically linked 32-bit executables, tens of thousands of lines of C code, thousands of business scripts on an embedded interpreter. No tests.
The Y2038 problem — 32-bit time_t overflow on January 19, 2038 — would render it inoperable. A previous attempt at full 64-bit migration had been abandoned. Not because it didn’t compile. It compiled fine. It crashed at runtime.
Segfaults. Silent memory corruption. Values that looked right in the debugger but weren’t. The classic LP64 trap: sizeof(long) changes from 4 to 8, every pointer-to-int cast breaks, struct packing shifts, binary formats become unreadable. Compiled cleanly on 64-bit. Destroyed itself the moment it ran.
The conclusion at the time: too much effort. Impossible with the available resources. And 2038 is far, far away.
It’s 2026. I’m migrating the scripting layer from a proprietary language to Python 3. That migration builds on sand if the underlying C layer can’t survive 2038. I don’t build on sand. So I looked at it.
Avoid Catastrophe First, Build Long-Term Foundation Second
The previous team asked: “How do we make this code 64-bit?” Right question, wrong time. Surviving January 2038 comes first. Both problems are rooted in 32-bit, but one is far simpler than the other.
Full 64-bit changes everything: sizeof(long), sizeof(void*), struct layouts, binary file formats, shared memory, the embedded Python interpreter’s LONG_BIT constant. It’s a system-wide earthquake for a codebase that has been 32-bit for three decades.
The question nobody asked: “Can we make time_t 64-bit without changing anything else?”
The Compile Flag Nobody Mentioned
Since glibc 2.34, there’s a flag: _TIME_BITS=64. It does exactly one thing: makes time_t 8 bytes on a 32-bit build. sizeof(long) stays 4, sizeof(void*) stays 4, struct layouts don’t change, binary formats don’t break. The Python interpreter doesn’t care.
I compiled the entire codebase with it. Zero new errors.
Not “few errors.” Zero.
The codebase’s custom types happened to use int instead of long. LP64-safe without knowing it. Only time_t was the problem, and _TIME_BITS=64 fixes exactly that.
Following the Data
Zero compile errors doesn’t mean zero work. time_t is now 8 bytes, but every variable that stores a timestamp is still 4. The compiler tells you where: -Wconversion flagged ~60 sites. Every single one a potential silent truncation after 2038.
I classified them:
| Category | Count | Risk |
|---|---|---|
Direct time() into 4-byte variable |
~40 | Truncation after 2038 |
time((time_t*)&int_var) — writes 8 bytes into 4 |
handful | Stack corruption. Silent. Now. |
| Timestamp write functions — cast to 4-byte type | several functions | DB writes truncated |
| Dead code / unreachable | ~10 | None |
The stack corruption sites were the critical find. time((time_t*)&int_var) writes 8 bytes into a 4-byte variable. The compiler doesn’t warn about this — it’s a valid pointer cast. You find it with grep, not with -Wall. Lots and lots and lots of greps. After _TIME_BITS=64, this overwrites adjacent stack variables. Before 2038, it’s harmless because the value fits in 4 bytes. After 2038, it’s silent data destruction.
The Real Deadline Isn’t 2038
While tracing timestamp handling, I found a sentinel value — a hardcoded constant used to mark invalid timestamps. Any timestamp after a certain date in 2036 would be rejected as invalid. Two years before the actual overflow.
One line of code. Invisible without reading the constant definitions. It means the real deadline isn’t January 2038. It’s early 2036.
One-line fix buys two years. But only if you know it’s there.
The Database Bottleneck
The system has over a thousand tables with epoch timestamps. That sounds like a massive migration. It isn’t.
Every table that auto-generates a timestamp on write calls the same C function. That function casts the timestamp to a 4-byte unsigned type before passing it to the database layer. Fix that one cast, and the majority of tables are safe.
A handful of tables use signed types for timestamps. Those overflow in 2038 regardless. They need schema changes. But several tables in the system already use the target format, proving the migration pipeline works end-to-end. And the pipeline is automated — schema definition to struct generation to database DDL, with batch capability and dry-run mode.
The remaining tables use unsigned types. Safe until 2106. Not on the critical path.
Along the way, I found a hardcoded SQL constant exceeding the signed 32-bit range. Broken today, not in 2038. Typical for legacy systems: you go looking for future problems and find present ones.
The Scope
Previous estimate: 12–18 months, “entire system rebuild, if it works at all.”
Actual scope: a sentinel fix (1 day), a compile flag (2–3 days), ~60 targeted C fixes (2–3 weeks), a handful of schema migrations (days), canary rollout and validation (3–4 weeks).
Total: 7–10 weeks. One engineer.
Not 18 months. Not a team. Not a rewrite. A different question, a compile flag, and following the data through every layer.
The previous attempt wasn’t wrong — right problem, wrong time. Full 64-bit is the correct long-term solution. _TIME_BITS=64 buys a decade at a fraction of the cost and risk. Do the surgery you need, not the surgery that’s technically ideal.
Classify before you count. ~60 warnings look manageable. A handful of them are silent stack corruption. Without classification, you either panic at the number or miss the ones that matter. And follow the data end-to-end — the DB strategy changed three times as I traced each layer deeper: “a couple of tables need changes” → “dozens” → “over a thousand” → “but only a handful are critical, and the bottleneck is one C function.”
As engineers, we want to do things right. Sometimes you have to accept doing them ugly to keep going. If you find a hole in your boat, you fix it properly once you’re safe on land. As long as you’re on the sea? Grab wood, grab nails, and close the hole.
When the previous attempt says “impossible,” check if it was the right question.