Date of birth validation has a reputation for being easy, and that reputation is undeserved. The field takes one value, the value is a date, and a date is a solved problem — except that it is not, because a birth date is also the input to an age, an age is compared against a threshold, and both the age and the threshold depend on what day it is where the comparison happens.
This article collects the edges where correct-looking implementations give wrong answers, and explains which of them belong in a test suite before a release rather than after a complaint.
What makes a birth date different from other dates?
A booking date, an invoice date and a delivery date all sit near the present and none of them has to be converted into a number of years. A birth date is different in three ways at once. It is far in the past, so it spans dozens of leap-year cycles and at least one rule change. It is used to compute a person’s age, which means arithmetic rather than formatting. And it is often compared against a legal threshold, which means the answer has consequences.
The three ways a birth date differs from those dates:
- It is far in the past, so it spans dozens of leap-year cycles and at least one rule change
- It is used to compute a person’s age, which means arithmetic rather than formatting
- It is often compared against a legal threshold, which means the answer has consequences
The consequence of getting it wrong is asymmetric. Rejecting a valid birth date turns away a legitimate user; accepting a birth date that should have failed lets someone past a gate that exists to protect them. Both are real failures, and they arrive through different code paths.
Which dates do not exist at all?
Any date that does not exist on the calendar should be rejected, which sounds obvious until the leap-year rule is written out. A year is a leap year when it is divisible by four, except that century years must be divisible by four hundred, so one century year that looks divisible by four is not a leap year and another is.
The practical cases that matter are the two century years either side of the present. One of them is not a leap year, so the twenty-ninth of its second month is not a valid date; the other is a leap year, so the same day is valid. Implementations that apply only the divisible-by-four rule accept one impossible date and reject one real one, and the error is invisible for most of the year.
The same reasoning extends to the rest of the calendar: months have different lengths, some inputs arrive in day-first order and some in month-first order, and a two-digit year is ambiguous by construction. A field that accepts a bare two-digit year will place some people a century away from their real age.
How is age actually calculated?
Age is a difference in whole years, computed by comparing the birth date against the current date component by component: subtract the birth years, then subtract one more if the birthday has not yet arrived this year. The birthday itself is the boundary, and the common convention is that a person is a year older on the day, not the day after.
That definition has a subtlety worth stating, because it is where most off-by-one errors live. The comparison is between two calendar dates, not between two instants. Two people born on the same calendar day are the same age on that day even if they were born at different times, and someone born late in the evening does not become a year older at that time of day.
Converting either date to a count of seconds and dividing is the usual wrong implementation. It drifts across leap years, it makes the answer depend on the time of day, and it produces an age that changes at an arbitrary hour rather than at midnight.
Which “today” does the check use?
Whichever clock the server happens to be on, unless someone decided otherwise. That is the root of a class of bugs that only appear around midnight and only for users in some time zones: a birth date that satisfies an age threshold for the user’s local day fails for the server’s day, or the reverse.
The clean way to think about it is that the birth date is a date and carries no time at all. Store it as a plain date with no time zone attached, and do the age comparison against a date that was derived from an explicit policy — usually the user’s local date for an interactive check, and a fixed reference date for anything that has to be reproducible. Mixing a timezone-free birth date with a timezone-aware current instant is how the ambiguity gets in.
There is a related trap for tests. A test that computes its expectation from the system clock passes today and fails on someone’s birthday, or in a leap year, or after a policy change. Tests that assert on age need the current date injected, not read.
Do all calendars agree on the same day?
No. The reckoning used by the global default calendar is not the only one in use, and several regions maintain their own systems for civil purposes. A date written in one calendar does not correspond to the same written date in another, and the same instant can appear with different year, month and day depending on which system the form expects.
For a form builder the practical rule is to be explicit rather than clever: state which calendar the field expects, accept the components in a stated order, and if a region’s local calendar matters to your users, treat the conversion as a product decision with its own field rather than an automatic transformation nobody can see. Converting silently is worse than not converting, because the wrong value is indistinguishable from a typed one.
Where do placeholder dates cause trouble?
Three defaults do measurable damage. The first of January of a round year is the most common placeholder in the world, and a form that quietly treats it as a real birth date will have a cluster of users who all turn out to share a birthday. A zero or empty value masquerading as a date is worse, because it can compute to an age of several centuries and pass an over-thirteen check while failing every other check silently.
The third is the placeholder that is technically valid and obviously wrong, such as the earliest date a picker allows. All three share a symptom: they make a bad record look complete, so downstream code never gets the chance to reject it.
That is the argument for generated records in tests. Values built by the identity and test data generator are spread rather than clustered, which means a form under test sees a range of real-shaped dates instead of the same placeholder over and over. They are synthetic values for software testing and nothing more — not anyone’s real birth date, and not a document that could be used as somebody’s identity.
For developers: boundaries worth asserting
Fix the reference date in the test, then assert the exact boundary rather than something near it. Four values cover most of the risk for any threshold: the birth date that makes the person exactly the threshold age today, the one that is a single day short, the one a single day past, and one born on a leap day.
Beyond those, keep the storage and comparison rules narrow: store a calendar date with no time zone, derive age on demand rather than storing it, and never let a placeholder date become indistinguishable from a real one. A sentinel value outside the plausible range, rejected by validation, is preferable to a plausible default that slips through checks. And keep policy numbers in configuration, because thresholds change and a hard-coded one is a release away from being wrong.
Next steps
Take the age check in your product and run those four boundary values through it with a fixed reference date; if any of them returns the wrong answer, the bug is in the comparison rather than in the form. Then pull a spread of generated birth dates from the identity generator and confirm the field stores them unchanged, including one at the start of a month and one in a non-default calendar order, so that a locale change cannot silently reorder them. The age verification testing article covers the thresholds those dates are usually compared against.