Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

Python strptime Rejects February 29 Without a Year: Parse Month and Day Explicitly

Fix yearless February 29 parsing with an explicit leap year, validate malformed dates, and keep recurring reminders separate from real calendar dates.

By ON Lab3 min readSources checked November 10, 2026 (KST)

Parsing a birthday such as 02/29 with datetime.strptime(text, "%m/%d") can fail even though February 29 is a valid recurring birthday. The parser needs a complete date. In older Python versions, the missing year defaults to 1900, which is not a leap year. A later .replace(year=2024) cannot help because construction has already failed.

Keep two requirements separate: accepting a recurring month and day, and assigning it to a particular calendar year. Use an explicit leap year while parsing the recurring value, retain the month and day, and validate any actual target year separately. Do not store the placeholder as a person's birth year.

Reproduce the failure before changing the input

This diagnostic reports its interpreter and records failures instead of suppressing them:

import sys
import warnings
from datetime import datetime

print(sys.version.split()[0])
for text in ("02/28", "02/29", "04/31"):
    with warnings.catch_warnings(record=True) as seen:
        warnings.simplefilter("always")
        try:
            result = datetime.strptime(text, "%m/%d").isoformat()
        except ValueError as exc:
            result = f"ValueError: {exc}"
        print(text, result, [type(w.message).__name__ for w in seen])

The Python 3.11 runtime used for this guide accepted February 28 with year 1900 and rejected February 29. That observation is specific to that interpreter. Python's 3.14 documentation describes the warning introduced in 3.13 for a day without a year. The 3.15 documentation specifies a ValueError for %d without a year, including ordinary days. Check the version used by your application rather than depending on the old fallback.

Parse the recurring value with an explicit leap year

Here is a complete function with a deliberately narrow input contract. It accepts exactly two ASCII digits for the month, a slash, and two for the day. It returns a pair, keeping the placeholder year out of the application's stored value.

import re
from datetime import date, datetime

def parse_month_day(text):
    if re.fullmatch(r"[0-9]{2}/[0-9]{2}", text) is None:
        raise ValueError("expected MM/DD with two digits per field")
    parsed = datetime.strptime(text + ";2000", "%m/%d;%Y")
    return parsed.month, parsed.day

def in_year(month_day, year):
    month, day = month_day
    return date(year, month, day)

assert parse_month_day("02/29") == (2, 29)
assert in_year(parse_month_day("02/29"), 2024).isoformat() == "2024-02-29"
assert in_year(parse_month_day("12/31"), 2025).isoformat() == "2025-12-31"

for invalid in ("04/31", "02/30", "13/01", "00/10", "2/29", "02/29x"):
    try:
        parse_month_day(invalid)
    except ValueError:
        pass
    else:
        raise AssertionError(invalid)

try:
    in_year(parse_month_day("02/29"), 2025)
except ValueError:
    print("February 29 needs an explicit policy in a non-leap year")
else:
    raise AssertionError("invalid target date accepted")

These checks describe this function's contract, not every format accepted by strptime. If your form permits 2/9, change the input contract deliberately and test it. Do not silently accept trailing text while trying to repair the leap-day failure.

Decide what February 29 means in a non-leap year

A reminder service might use February 28, March 1, or only leap years. Pick that behavior as a product rule. The parser above intentionally raises when asked to build February 29 in 2025; it does not quietly choose a different day. Catch that failure where the reminder policy is defined and test the selected policy separately.

If the input represents an actual dated event, request or preserve its real year instead. For example, parse 2024-02-29 with an explicit year format. A placeholder solves a yearless month/day input problem; it does not supply missing historical information.

Check the actual application execution path

Run the examples with the interpreter that runs the worker, notebook, or web service. Preserve the version and observed warnings with the result. Then exercise the form or import path with February 28, February 29, an impossible day, and malformed input. Check the saved representation too: it should contain a recurring month/day or a real date according to the application's contract.

This issue concerns constructing a calendar date from incomplete input. If an already valid timestamp is shifting when displayed, investigate offsets and time zones separately; see local wall time and UTC boundaries. Likewise, changing an ISO parser does not repair an omitted year in a different input format; see ISO timestamps and trailing Z.

Sources & further reading