Birthdays That Break Software: Date Edge Cases for QA
February 29, January 1 defaults, the Excel 1900 bug, off-by-one ages: the date-of-birth edge cases that crash real systems, and the fixtures that catch them.
By VincentPublished August 6, 2026Last updated August 6, 20269 min read
After we published names that break software, the natural question was which field to distrust next. The answer is the one that looks safest: date of birth. A birthday has a fixed format and only 366 possible month-day values, yet it has frozen an entire product line of music players, taken down a cloud platform for a day, and quietly corrupted age calculations in systems that passed every happy-path test. The difference between a name and a birthday is what software does with it: a name is mostly stored and displayed, but a birthday is computed against — age, eligibility, cohorts, even the zodiac sign on a generated profile — and every one of those computations replays the calendar's exceptions.
Why dates of birth are harder than they look
The calendar is an API with special cases baked in centuries ago: months of four different lengths, a day that exists only three years in four, a leap rule with an exception and an exception to the exception, and two competing conventions for writing the whole thing down. A stored birthday touches all of them, repeatedly, for as long as the record lives. Worse, the failures are asymmetric: rejecting a valid date locks a real person out of a signup form, while accepting an invalid one plants a value that detonates later, in whatever downstream job next does arithmetic on it.
The edge cases that actually break systems
29 February
About one birth in 1,461 lands on a leap day, so leap-day users are not hypothetical — and the rule that creates their birthday is the one developers most often half-implement. A year is a leap year if it is divisible by 4, except centuries, unless the century is divisible by 400: 2000 was a leap year, 1900 was not, and 2100 will not be, which means a hand-rolled divisible-by-4 check works flawlessly for the next seventy-odd years and then stops [leap-year-wiki]. Even lawmakers have had to patch this edge case: the United Kingdom, Hong Kong and Taiwan all legislate that a leap-day birthday falls on 1 March in common years, because contracts and coming-of-age rules need an answer for a birthday that does not exist three years out of four [leap-year-wiki]. Your age-calculation code needs the same explicit decision — and a test that pins it.
The leap day's greatest hits are well documented [leap-bug-wiki]. On 31 December 2008 — the 366th day of a leap year — Microsoft's Zune 30 players worldwide froze on boot: the clock driver's year-conversion loop subtracted 365 days at a time and had no branch for a remainder of exactly 366, so it spun forever until the calendar rolled over at midnight. On 29 February 2012, Windows Azure went down when internal transfer certificates were issued with a validity of exactly one year: 29 February 2012 plus one year is 29 February 2013, a date that does not exist, and the resulting certificate failures cascaded into a day-long outage. Neither bug was in date parsing. Both were in date arithmetic — the part that runs long after the input form said everything was fine.
The year 1900, and Excel's deliberate bug
Spreadsheets are where date-of-birth data goes to get mangled, and the flagship example is intentional. Excel's 1900 date system treats 1900 as a leap year: serial number 60 corresponds to 29 February 1900, a day that never happened. Microsoft documents the behavior and kept it on purpose, for compatibility with Lotus 1-2-3, which shipped the same error first [excel-1900]. The practical consequences for birthday data are real: weekday calculations for early-1900 dates are wrong by a day, and the 1900 system cannot represent any date before 1 January 1900 at all — paste a centenarian's 1899 birthday into a column of dates and it silently becomes text, which then sorts, filters and imports differently from every value around it. If your data pipeline round-trips through a spreadsheet anywhere — and at some point, someone's will — your date fixtures should include the dates that expose it.
Ages that are off by one
The most common birthday bug never crashes anything: subtracting birth year from current year overstates a person's age for the entire portion of the year before their birthday — up to 364 days of being wrong. In systems that gate anything on 13, 18, 21 or 65, that error is not cosmetic; it grants or denies eligibility. Correct age math compares the full month-day pair, and the boundary it creates is exact: a person turns 18 on their birthday, not the day before, not the day after. The only tests that prove this are fixtures pinned to the boundary — a date of birth exactly 18 years before the reference date, and the same date shifted by one day.
The second off-by-one arrives through timezones. A date of birth is a calendar date, not an instant — nobody's birthday shifts when they fly — but the moment it is stored in a datetime column it acquires an implicit midnight, usually UTC. Render that value in any timezone west of Greenwich and it displays as the previous day: born 10 May, shown 9 May. The fix is to keep dates of birth as plain dates in ISO 8601 form (1990-05-10) end to end, and let nothing 'helpfully' promote them to timestamps [iso8601]. If you have ever seen a bug report titled 'my birthday is wrong by one day', this is almost always why.
1 January, and other defaults that look like data
Plot the birthdays in any large real-world database and 1 January towers over every other date. Some of that is software — epoch sentinels like 1970-01-01, the zero value of Unix time, leaking out of code that conflated 'unset' with 'the beginning of time' [unix-time] — but much of it is genuine data recorded honestly. When a person's true date of birth is unknown, which is common in refugee and immigration paperwork from regions where births go unregistered, officials have long filled in 1 January: of the roughly 80,000 refugees admitted to the United States in 2009, about 11,000 had a 1 January birthday on file [npr-jan1]. The lesson for engineers cuts both ways. A heap of 1 January birthdays is not proof of corruption — and a duplicate-detection or identity-matching system that treats 'same date of birth' as strong evidence will quietly merge thousands of distinct people who share nothing but a clerk's convention.
Two-digit years and ambiguous formats
The Year 2000 problem was, at its core, a date-of-birth problem: two-digit years forced every system to guess a century, and windowing rules that read '25' as 1925 or 2025 depending on a pivot value classified centenarians as newborns and vice versa [y2k-wiki]. The two-digit year is mostly gone from storage, but it survives in date pickers, CSV exports and OCR'd paper forms — and its cousin, format ambiguity, is fully alive: 03/04/2001 is the 4th of March in New York and the 3rd of April in London. Unambiguous interchange is a solved problem — write dates as ISO 8601 and the parsing question disappears [iso8601] — but only for the systems you control; test data should still include dates whose day value exceeds 12, because those are the only ones that cannot be silently transposed. And the next epoch is already scheduled: 32-bit Unix timestamps overflow on 19 January 2038, which is closer to today than the Y2K remediation effort is behind us [unix-time].
A birthday fixture set worth copying
| Date of birth | What it exercises | Expected behavior |
|---|---|---|
| 2000-02-29 | The 400-year rule — 2000 really was a leap year | Accepted; age correct in common years |
| 1996-02-29 | Ordinary leap day | Accepted; birthday handling defined for common years |
| 1900-02-29 | The century rule — this day never existed | Rejected; catches divisible-by-4-only leap logic and Excel's serial 60 |
| 2001-02-29 | Leap day in a common year | Rejected |
| 2000-04-31 | Day 31 in a 30-day month | Rejected |
| Reference date minus exactly 18 years | The eligibility boundary | Person is 18 today — not yesterday |
| One day after that | The other side of the boundary | Person is 17 until tomorrow |
| 1970-01-01 | Unix epoch sentinel collision | Accepted, but distinguishable from an unset default |
| 1900-01-01 | Legacy default and spreadsheet epoch | Accepted; survives an export-import round trip |
| 1899-12-31 | Pre-1900 centenarian | Survives a spreadsheet round trip without turning into text |
| 2001-12-13 | Day value over 12 | Cannot be month-day transposed silently |
| Tomorrow's date | Future date of birth | Rejected at the boundary, not just 'far future' |
Run every fixture through the whole lifecycle, not just the input form: create the record, display it in at least two timezones, compute the age on both sides of the birthday, and round-trip it through whatever export formats your system supports. The 29 February and 1899 rows fail most often in the round trip, and the boundary rows fail most often in a batch job that somebody wrote years after the form validation.
How our generator handles birthdays — including two bugs we shipped
Every profile from our identity generator carries a birthday, an age and a zodiac sign, and the generation is deterministic from a seed: the engine picks a target age between 18 and 80, draws a month and day anywhere in the calendar, then derives the birth year that makes the age exact as of a fixed reference date — no wall-clock reads, so the same seed always produces the same person. One consequence is deliberate: the engine never produces 29 February. The leap day only exists in leap years, so allowing it would constrain birth years to leap years and skew the age distribution — and since a uniform generator would hit it only once in roughly 1,461 draws, it is a poor way to test that path anyway. If your suite needs leap-day coverage, take the fixtures from the table above; random data is the wrong tool for a boundary you can name.
We are writing this guide with some humility, because we shipped two of these bugs ourselves. Our original implementation derived the year first and then constrained the month and day to fall on or before the reference date's — and the engine's default reference was 1 January, so the 'anywhere in the calendar' draw collapsed to a single value: for a while, every identity this site generated without an explicit reference date was born on 1 January, a Capricorn, all of them. The same routine also capped every month at 28 days, so no generated person was ever born on the 29th, 30th or 31st. Both bugs produced individually flawless records — every date valid, every age exact — which is precisely why row-level validation never flagged them.
The conclusion mirrors the one we reached about names: validate less, test more. Reject the dates that cannot exist, accept everything that can — including the leap day, the centenarian and the person who turns 18 today — and spend the effort you save on fixtures that walk the boundaries and on aggregate checks that catch what per-row validation never will. If you want the same treatment of the field next to this one, start with names that break software; if you want a thousand internally consistent profiles to run your own histograms on, the generator is free and seed-reproducible.
References & sources
- Leap year — leap day birthdays and the Gregorian rule — Wikipedia
- Leap year problem — Wikipedia
- Excel incorrectly assumes that the year 1900 is a leap year — Microsoft Learn
- Why So Many Immigrants Have Birthdays On Jan. 1 — NPR
- Year 2000 problem — Wikipedia
- ISO 8601 — date and time format — Wikipedia
- Unix time — Wikipedia
Frequently asked questions
Is 29 February a valid date of birth?+
Yes. Roughly one in 1,461 people is born on a leap day, so any system that stores dates of birth will meet one. Your software must accept 29 February in leap years, reject it in common years, and decide what to do in the years when the birthday does not exist — a question big enough that lawmakers have answered it explicitly: the United Kingdom, Hong Kong and Taiwan all treat a leap-day birthday as falling on 1 March in common years.
Why do so many database records show a 1 January birthday?+
Two reasons, neither of them astrology. First, when a real date of birth is unknown — common in refugee and immigration paperwork — registrars have long filled in 1 January, so genuine records heap on that date. Second, software defaults: epoch values like 1970-01-01 and placeholder dates entered to get past a required field. A spike of 1 January birthdays is a data-quality signal, not a demographic fact, and identity-matching logic that weights date of birth should expect it.
What dates of birth should I include in test fixtures?+
At minimum: 29 February 2000 (valid — the 400-year rule makes 2000 a leap year), 29 February 1900 and 29 February 2001 (both invalid, for different reasons), a date exactly 18 years before your reference date and the day after it (the eligibility boundary), 1 January 1970 and 1 January 1900 (sentinel collisions), 31 December and a pre-1900 date for round-trip testing, and a date in the future (must be rejected). Run each through creation, display, age calculation, and an export-import cycle.
Why doesn't this site's generator produce 29 February birthdays?+
By design. Our engine keeps the generated age exact by deriving the birth year from the month and day, and 29 February only exists in leap years — allowing it would constrain birth years to leap years and distort the age distribution. Uniform random dates would only hit the leap day about once in 1,461 draws anyway, so if you need to test the leap-day code path, add those fixtures by hand rather than hoping a generator lands on them.