🐍 PythonDate / Time
Date Validation in Python
Validate dates in Python — correctly handle leap years, validate date ranges, verify minimum age for GDPR / COPPA compliance, and normalise user input to ISO 8601 canonical form.
Also available in Node.js
Contents
1. Leap year rules
A year is a leap year if it is divisible by 4 — except century years, which must also be divisible by 400.
Divisible by 4? → candidate Divisible by 100? → NOT a leap year (century rule) Divisible by 400? → IS a leap year (override) 2000 → ÷400 → leap year ✓ 1900 → ÷100 but not ÷400 → NOT a leap year ✗ 2024 → ÷4, not ÷100 → leap year ✓ 2026 → not ÷4 → NOT a leap year ✗
⚠️2000-02-29 is valid; 1900-02-29 is not. Python's
datetime handles this correctly, but many frontend validators do not. Always validate server-side.2. API response structure
Endpoint: GET /v0/date?value=…
{ "valid": true, "date": "2026-03-12", "year": 2026, "month": 3, "day": 12, "dayOfWeek": "Thursday", "isLeapYear": false, "timestamp": 1741737600000 }
timestamp is milliseconds since Unix epoch (UTC midnight). Use it for range comparisons — avoids timezone arithmetic entirely.
3. Accepted input formats
# ISO 8601 (preferred) await iv.date("2026-03-12") # DD/MM/YYYY (European) await iv.date("12/03/2026") # MM/DD/YYYY (US) await iv.date("03/12/2026") # Long-form await iv.date("March 12, 2026") # All normalise to: date = "2026-03-12"
4. Date range validation
Validate both dates in parallel with asyncio.gather, then compare timestamps.
async def validate_date_range(start: str, end: str) -> dict: start_res, end_res = await asyncio.gather( iv.date(start), iv.date(end), ) if not start_res.valid: raise ValueError(f"Invalid start date: {start!r}") if not end_res.valid: raise ValueError(f"Invalid end date: {end!r}") # timestamp is UTC midnight in ms — safe cross-timezone comparison if start_res.timestamp >= end_res.timestamp: raise ValueError("Start date must be before end date") days = (end_res.timestamp - start_res.timestamp) / (1000 * 60 * 60 * 24) return { "start": start_res.date, "end": end_res.date, "days": int(days), }
5. Age verification (GDPR / COPPA)
ℹ️GDPR requires age 16+ for data processing consent in most EU countries (some use 13). US COPPA requires age 13+ for online services directed at children. Always use UTC for age calculations to avoid timezone edge cases on the user's birthday.
from datetime import datetime, timezone async def verify_minimum_age(birth_date: str, min_years: int = 18) -> bool: result = await iv.date(birth_date) if not result.valid: raise ValueError(f"Invalid birth date: {birth_date!r}") # Use UTC midnight timestamps to avoid timezone bugs now_ts = datetime.now(timezone.utc).timestamp() * 1000 # ms age_ms = now_ts - result.timestamp age_years = age_ms / (1000 * 60 * 60 * 24 * 365.25) return age_years >= min_years # FastAPI endpoint example from fastapi import HTTPException @app.post("/register") async def register(birth_date: str): if not await verify_minimum_age(birth_date, min_years=18): raise HTTPException( status_code=422, detail={"error": "age_below_minimum", "min_age": 18}, )
6. Edge cases
| Input | Valid? | Note |
|---|---|---|
| 2000-02-29 | ✓ | Leap year — valid |
| 1900-02-29 | ✗ | Century year, not ÷400 — invalid |
| 2026-02-29 | ✗ | 2026 is not a leap year |
| 2026-04-31 | ✗ | April has 30 days |
| 2026-12-31 | ✓ | Last day of year |
| 2026-01-01 | ✓ | First day of year |
| 9999-12-31 | ✓ | Far future — valid ISO 8601 |
| 0000-01-01 | ✗ | Year 0 not in Gregorian calendar |
7. Full example with asyncio
import asyncio import os from datetime import datetime, timezone from isvalid_sdk import IsValidConfig, create_client iv = create_client(IsValidConfig(api_key=os.environ["ISVALID_API_KEY"])) async def validate_date(value: str) -> dict: result = await iv.date(value) if not result.valid: raise ValueError(f"Invalid date: {value!r}") return result # Validate a date range: start must be before end async def validate_date_range(start: str, end: str) -> dict: start_res, end_res = await asyncio.gather( iv.date(start), iv.date(end), ) if not start_res.valid: raise ValueError(f"Invalid start date: {start!r}") if not end_res.valid: raise ValueError(f"Invalid end date: {end!r}") if start_res.timestamp >= end_res.timestamp: raise ValueError("Start date must be before end date") return {"start": start_res.date, "end": end_res.date} # Age verification — compare against today's UTC timestamp async def verify_minimum_age(birth_date: str, min_years: int = 18) -> bool: result = await iv.date(birth_date) if not result.valid: raise ValueError(f"Invalid birth date: {birth_date!r}") now_ts = datetime.now(timezone.utc).timestamp() * 1000 # ms age_ms = now_ts - result.timestamp age_years = age_ms / (1000 * 60 * 60 * 24 * 365.25) return age_years >= min_years async def main(): # Leap year res = await iv.date("2000-02-29") print(res.is_leap_year) # True # Range booking = await validate_date_range("2026-06-01", "2026-06-14") print(booking) # {'start': '2026-06-01', 'end': '2026-06-14'} # Age check of_age = await verify_minimum_age("2000-03-12") print(of_age) # True asyncio.run(main())
8. Summary checklist
✓Accept ISO 8601, EU, US, and long-form date formats
✓Use timestamp (ms UTC) for range and age comparisons
✓Handle century years correctly (÷400 rule for leap years)
✓Validate both start and end dates before range comparison
✓Use UTC midnight for age checks — avoid timezone bugs
✓Enforce GDPR (16) / COPPA (13) minimum age requirements
✓Run parallel validation with asyncio.gather
✓Return 422 with reason on invalid date input