Why Date Math Is Not Simple Arithmetic
Adding numbers is straightforward: 5 + 3 is always 8. Adding dates is not. "January 31 plus one month" has no clean answer — February 31 does not exist. Should the result be February 28, March 1, or March 3? Different libraries make different choices, and none of them is objectively correct. This fundamental ambiguity makes date calculations one of the most bug-prone areas in programming.
The root problem is that our calendar is not a uniform measurement system. A month is not a fixed duration — it is 28, 29, 30, or 31 days depending on which month and whether it is a leap year. A year is 365 or 366 days. Even a "day" is not always 24 hours: daylight saving transitions create 23-hour and 25-hour days. You cannot treat dates as simple integers without hitting edge cases that produce wrong results.
Consider a real example: calculating someone's age. If a person was born on February 29, 2000, how old are they on March 1, 2023? Are they 23 (because March 1 is after their birthday month) or still 22 (because February 29 did not occur in 2023, so their birthday has not passed)? Legal systems in different countries answer this differently. The UK considers a leap-day baby to turn a year older on March 1 in non-leap years. Other jurisdictions use February 28.
Even "how many days between two dates" requires care. Is it inclusive or exclusive of the endpoints? "Days between January 1 and January 3" — is that 2 (exclusive) or 3 (inclusive)? Date difference calculators need to document their convention. Business contexts usually want inclusive counting (a rental from the 1st to the 3rd is 3 days), while programming APIs usually return exclusive differences (subtract timestamps and divide by 86400).
Leap Year Rules
The basic rule everyone knows: a year is a leap year if divisible by 4. The rule most people forget: except years divisible by 100 are not leap years. The rule almost nobody remembers: except years divisible by 400 are leap years. So 2024 is a leap year (divisible by 4), 1900 was not (divisible by 100 but not 400), and 2000 was (divisible by 400). The next century year that will be a leap year is 2400.
This 400-year cycle exists because a tropical year (the time Earth takes to orbit the Sun) is approximately 365.2422 days. The Julian calendar (just "divisible by 4") assumes 365.25 days, which drifts by about 3 days every 400 years. The Gregorian calendar correction (skip 3 leap years every 400 years) reduces the average to 365.2425 days — close enough that the drift is only 1 day every 3,236 years.
For programmers, the leap year check is a common interview question with a common wrong answer. The naive check (year % 4 === 0) fails on century years. The correct implementation: (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0). Note the operator precedence — the AND binds tighter than OR, so this reads: "divisible by 4 but not by 100, OR divisible by 400." It is also important for validation: February 29 is valid only in leap years.
Leap seconds are a separate issue that most applications can ignore. They are inserted irregularly (about every 18 months, decided by the International Earth Rotation Service) to keep UTC aligned with Earth's slowing rotation. JavaScript Date objects and most OS clocks ignore leap seconds entirely — they pretend every day has exactly 86,400 seconds. This simplification is wrong by at most 1 second per 18 months, which matters for satellite navigation but not for web applications.
Working Days and Business Day Calculations
Calculating working days (business days) between two dates means counting days while excluding weekends and optionally public holidays. This is simple in concept but surprisingly fiddly in implementation. A naive loop that iterates day by day works but is slow for large ranges. An O(1) formula exists: count full weeks (5 working days each), then handle the remaining partial week by checking which days of the week the remainder spans.
The formula approach: calculate total days between the dates, divide by 7 to get full weeks (each contributing 5 working days), then handle the remainder. The remainder depends on which day of the week you start — starting on a Monday with 3 remaining days gives 3 working days; starting on a Friday with 3 remaining days gives only 1 (Friday itself, Saturday and Sunday are skipped). The lookup table for remainders by start day makes this fast without iteration.
Public holidays add a layer of complexity that defeats pure formulas. You cannot avoid maintaining a list of holidays for each relevant jurisdiction. US federal holidays include some that fall on fixed dates (July 4, December 25) and others that follow rules (third Monday in January, fourth Thursday in November). UK bank holidays are a fixed list published annually by the government. Moving holidays (Easter, Eid, Chinese New Year) require their own algorithms or lookup tables.
Multi-jurisdiction business day calculations — common in international finance — require intersection logic. A payment from New York to London settles on days that are working days in both cities. If July 4 is a US holiday and the August bank holiday falls on a Monday, neither of those days counts. Financial libraries (like QuantLib) maintain extensive holiday calendars for exactly this purpose. For simple applications, a configurable list of excluded dates is usually enough.
// Calculate working days between two dates (excluding weekends)
function getWorkingDays(start: Date, end: Date, holidays: Date[] = []): number {
// Normalize to start of day to avoid time issues
const startDate = new Date(start.getFullYear(), start.getMonth(), start.getDate());
const endDate = new Date(end.getFullYear(), end.getMonth(), end.getDate());
if (endDate <= startDate) return 0;
const totalDays = Math.round(
(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
);
// Count full weeks and remaining days
const fullWeeks = Math.floor(totalDays / 7);
let workingDays = fullWeeks * 5;
// Handle remaining days
const remainingDays = totalDays % 7;
const startDay = startDate.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat
for (let i = 0; i < remainingDays; i++) {
const dayOfWeek = (startDay + i) % 7;
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
workingDays++;
}
}
// Subtract holidays that fall on working days
const holidaySet = new Set(
holidays.map(h => new Date(h.getFullYear(), h.getMonth(), h.getDate()).getTime())
);
for (const holidayTime of holidaySet) {
if (holidayTime >= startDate.getTime() && holidayTime < endDate.getTime()) {
const holidayDay = new Date(holidayTime).getDay();
if (holidayDay !== 0 && holidayDay !== 6) {
workingDays--;
}
}
}
return workingDays;
}
// Add N working days to a date
function addWorkingDays(start: Date, days: number, holidays: Date[] = []): Date {
const result = new Date(start);
let added = 0;
const holidaySet = new Set(
holidays.map(h => new Date(h.getFullYear(), h.getMonth(), h.getDate()).getTime())
);
while (added < days) {
result.setDate(result.getDate() + 1);
const dayOfWeek = result.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isHoliday = holidaySet.has(
new Date(result.getFullYear(), result.getMonth(), result.getDate()).getTime()
);
if (!isWeekend && !isHoliday) {
added++;
}
}
return result;
}
// Example usage
const start = new Date('2026-06-01'); // Monday
const end = new Date('2026-06-30'); // Tuesday
const holidays = [new Date('2026-06-19')]; // Juneteenth (Thursday)
console.log(getWorkingDays(start, end, holidays)); // 20 working days
console.log(addWorkingDays(start, 10, holidays)); // Skips weekends + holidayAge Calculation and the Birthday Problem
Calculating someone's age in years sounds trivial: current year minus birth year, minus one if their birthday has not occurred yet this year. But the edge cases pile up. What counts as "birthday has not occurred"? If someone was born on March 15 and today is March 15, is their birthday today (increment age) or has it not happened yet (wait until end of day)? Most systems consider the birthday to have occurred at the start of the birth date.
Leap day birthdays create a genuine ambiguity. A person born on February 29 — when is their birthday in non-leap years? Three common approaches: (1) treat March 1 as their birthday in non-leap years, (2) treat February 28 as their birthday in non-leap years, (3) consider that their birthday simply does not occur in non-leap years, so they age on March 1. Legal systems vary: the UK uses March 1, many US states use the person's choice for licensing purposes.
For software, the safest algorithm compares month and day rather than doing date subtraction. Calculate years = currentYear - birthYear. Then check: if current month is less than birth month, subtract 1. If current month equals birth month and current day is less than birth day, subtract 1. This handles leap day babies correctly under the "March 1 in non-leap years" convention because March > February.
Age in months or weeks requires more care. "Age in months" usually means complete calendar months — a baby born January 15 is 1 month old on February 15, 2 months on March 15, etc. But what about a baby born January 31? They cannot be 1 month old on February 31 (which does not exist). Convention says they turn 1 month on February 28 (or 29 in leap years) — the last day of the month. This "end-of-month clamping" rule is what most date libraries implement for month addition.
Month-End Overflow and Clamping
The core challenge: what happens when you add a month to a date that has no equivalent in the target month? January 31 + 1 month: February has no 31st. January 30 + 1 month: February has no 30th (except in leap years, when it has a 29th, still no 30th). Three strategies exist: overflow (roll into the next month), clamp (use the last valid day), and error (reject the operation).
JavaScript's Date object uses overflow: new Date(2026, 0, 31) then setMonth(1) gives March 3, 2026 — it adds the 3 extra days into March. This is technically consistent (February is treated as having "31 days" where days 29-31 spill into March) but almost never what users expect. No one asking "what date is one month after January 31" expects the answer to be in March.
Most date libraries (date-fns, Luxon, Day.js, Java's LocalDate, Python's dateutil) use clamping: if the target day exceeds the month's length, use the last day of the target month. January 31 + 1 month = February 28 (or 29 in leap years). This matches human intuition better — "next month" from the last day of a long month should land on the last day of the shorter month, not overflow into the month after.
Clamping has its own quirk: it is not reversible. January 31 + 1 month = February 28. February 28 - 1 month = January 28, not January 31. Round-tripping through month addition and subtraction does not return to the original date when clamping occurs. This is an inherent property of irregular month lengths — no algorithm can be both intuitive and perfectly reversible. Libraries document this behavior; your code should not assume round-trip consistency for month operations.
Date Difference Algorithms
The difference between two dates in days is straightforward: convert both to a common epoch (Unix timestamp, Julian Day Number, or days since some reference), subtract, and take the absolute value. Julian Day Numbers (continuous day count since January 1, 4713 BC) are the gold standard for astronomical calculations. Unix timestamps (seconds since January 1, 1970) work for modern dates but require care with daylight saving time — a "day" across a DST transition is 23 or 25 hours.
Differences in months or years are ambiguous and convention-dependent. How many months between January 31 and February 28? Is that exactly 1 month (end of Jan to end of Feb), or is it 0 months and 28 days (since February 28 is not the 31st)? Libraries differ. date-fns differenceInMonths returns 0 (floor of the exact difference), while some financial day-count conventions would say 1. Always check which convention your library uses.
Financial day-count conventions are a world of their own. The "30/360" convention pretends every month has 30 days and every year has 360 days — simplifying interest calculations at the cost of accuracy. "Actual/365" uses real day counts with a fixed 365-day year. "Actual/Actual" uses real day counts and real year lengths. Bond pricing, loan amortization, and interest accrual each specify which convention applies, and getting it wrong means calculating incorrect payment amounts.
For human-friendly "time ago" displays (like "3 months ago" or "2 years, 4 months, 12 days"), you need a cascading difference algorithm. Subtract years first, then adjust months (borrowing a year as 12 months if needed), then calculate remaining days. This mirrors how humans think about durations. The result is not a single number but a tuple (years, months, days) that cannot be reduced to a single unit without losing precision — "1 year and 1 day" is not the same as "366 days" because the year might not have been a leap year.
Libraries and Best Practices
In JavaScript, the built-in Date object handles basic operations but has well-documented pitfalls: months are 0-indexed (January is 0), overflow behavior for out-of-range values, mutable by default, and no timezone-aware arithmetic. For anything beyond simple timestamp storage and formatting, use a library. date-fns offers tree-shakable functions (addMonths, differenceInBusinessDays, isLeapYear). Temporal, the upcoming standard API (Stage 3 proposal, available via polyfill), fixes most Date issues with proper calendar and timezone support.
Python's datetime module provides date, time, datetime, and timedelta types. The timedelta type represents a duration in days and seconds — no months or years, because those are variable-length. For month and year arithmetic, use dateutil.relativedelta. For business day calculations, numpy.busday_count() is fast for large arrays. For timezone handling, always use zoneinfo (Python 3.9+) rather than the older pytz library.
The single most important practice: store dates in UTC (or as timezone-aware timestamps) and convert to local time only for display. Storing local times without timezone information makes your data ambiguous — "2026-11-01 01:30:00" happens twice in New York due to the fall-back DST transition. Store "2026-11-01T05:30:00Z" (UTC) and convert to "1:30 AM EST" or "1:30 AM EDT" at display time based on the user's timezone.
For date input validation, remember that February has 28 or 29 days (check leap year), April/June/September/November have 30 days, and the rest have 31. Reject impossible dates (February 30, September 31) rather than silently overflowing. Parse dates strictly — "2026-02-29" should fail validation in a non-leap year, not silently become March 1. User-facing date pickers should prevent invalid selections, and backend validation should reject them regardless of the frontend.