DEV Community

FUG
FUG

Posted on

February 29 Needs a Policy Parameter, Not a Hidden Exception

As of 2026-10-15

A birthday reminder fails on February 29, so a developer catches the exception and substitutes February 28. The program now runs. But the patch has also chosen a policy about when somebody's anniversary occurs in a non-leap year. That choice belongs in the feature's contract, where users and tests can see it.

We build the free age calculator at agecalc.fyi. Its English FAQ explicitly says that it uses March 1 for February 29 birthdays in non-leap years. That visible rule is the starting point here. We will implement two possible reminder conventions, without treating either one as a universal legal rule.

Keep the birthday unchanged

Someone born on February 29 should retain February 29 in the source record. Do not “repair” their date of birth to make a yearly scheduler simpler. A date of birth describes an event that happened; an anniversary date is a value derived for a particular target year.

The Gregorian leap-year rule makes years divisible by four leap years, except century years unless divisible by 400. Thus 2000 is a leap year, while 2100 is not. For application code, Python already supplies calendar.isleap; datetime.date validates calendar dates. Their behavior is documented in the Python calendar module and date API.

Source birthday Target year February 28 policy March 1 policy
2000-02-29 2027 2027-02-28 2027-03-01
2000-02-29 2028 2028-02-29 2028-02-29
2000-02-29 2100 2100-02-28 2100-03-01
2000-05-12 2027 2027-05-12 2027-05-12

The policy changes only the missing-date case. A normal birthday should not move, and a leap-year February 29 should remain February 29. Those are good invariants because they prevent a narrowly intended workaround from becoming a general month-end adjustment.

An executable policy, with boundary tests

Save this as birthdays.py and run python3 birthdays.py. It uses only the standard library. anniversary returns a date; completed_years computes a whole-year age under the same explicit convention. The latter is a calendar model, not an eligibility determination for a contract or public service.

from calendar import isleap
from datetime import date
import unittest


def anniversary(born, year, policy):
    if policy not in ("feb28", "mar1"):
        raise ValueError("Unknown leap-day policy")
    if born.month == 2 and born.day == 29 and not isleap(year):
        month, day = (2, 28) if policy == "feb28" else (3, 1)
        return date(year, month, day)
    return date(year, born.month, born.day)


def completed_years(born, as_of, policy):
    if as_of < born:
        raise ValueError("Reference date precedes birth")
    boundary = anniversary(born, as_of.year, policy)
    return as_of.year - born.year - int(as_of < boundary)


class BirthdayTests(unittest.TestCase):
    def test_non_leap_boundaries(self):
        born = date(2000, 2, 29)
        cases = [
            (date(2027, 2, 27), "feb28", 26),
            (date(2027, 2, 28), "feb28", 27),
            (date(2027, 2, 28), "mar1", 26),
            (date(2027, 3, 1), "mar1", 27),
        ]
        for as_of, policy, expected in cases:
            with self.subTest(as_of=as_of, policy=policy):
                self.assertEqual(completed_years(born, as_of, policy),
                                 expected)

    def test_centuries_and_ordinary_dates(self):
        born = date(2000, 2, 29)
        self.assertTrue(isleap(2000))
        self.assertFalse(isleap(2100))
        for policy in ("feb28", "mar1"):
            self.assertEqual(anniversary(born, 2028, policy),
                             date(2028, 2, 29))
            self.assertEqual(anniversary(date(2000, 5, 12), 2027, policy),
                             date(2027, 5, 12))
        self.assertEqual(anniversary(born, 2100, "mar1"),
                         date(2100, 3, 1))

    def test_rejected_inputs(self):
        with self.assertRaises(ValueError):
            date(2027, 2, 29)
        with self.assertRaises(ValueError):
            anniversary(date(2000, 2, 29), 2027, "rollover")
        with self.assertRaises(ValueError):
            completed_years(date(2000, 2, 29), date(1999, 1, 1), "mar1")


if __name__ == "__main__":
    unittest.main()
Enter fullscreen mode Exit fullscreen mode

For February 28, 2027, the year subtraction is 2027 - 2000 = 27. Under the March 1 policy the anniversary has not arrived, so subtract one and obtain 26. Under the February 28 policy it has arrived, so the result remains 27. The disagreement is exactly the declared policy, not an arithmetic error.

Do not hide the choice in exception handling

A broad except ValueError that substitutes the end of February can swallow unrelated input problems. An impossible source date, an unsupported year and a missing anniversary are different cases. Validate the source date first, then handle the known February 29 branch deliberately.

The function also rejects unknown policy strings even for ordinary birthdays. That catches misspellings immediately instead of letting them survive until the next leap-day record reaches production. A typed enum can serve the same purpose in a larger application.

Reminders and age displays need a shared contract

If the profile says a person is still 26 but the birthday banner says they turned 27 today, inspect whether two features used different conventions. Store the policy near the feature configuration and pass it to both calculations. Include it in bug reports alongside the birth date and reference date, using synthetic dates where possible.

For legal age gates, get the applicable rule and jurisdiction separately. Do not infer those requirements from a birthday-party reminder, a general calculator or a developer's default. The functions above deliberately do not contain a country table.

The web calculator also exposes a reference-date field. That is useful for inspecting a boundary without waiting for it to occur: compare the day before, the chosen anniversary and the day after. An executable test should use fixed dates for the same reason. A test that depends on today's date can pass for months while missing the only boundary that matters.

FAQ

Why not add 365 days to find the next birthday?

That advances a duration. It does not preserve a month and day across leap years.

Is February 28 always wrong?

No. It is one explicit anniversary convention. This calculator's displayed rule uses March 1 instead.

Should I change the stored birth date?

No. Keep the original valid date and derive each year's anniversary separately.

Does this calculate months and days too?

No. The example returns anniversaries and completed years only. Month-and-day decomposition needs its own documented convention.

The age calculator is free to use without signing up.

Inspect an age at a chosen reference date

Top comments (0)