Skip to content
SQLSimplified

Why DATEDIFF Gives the Wrong Age in SQL

DATEDIFF counts calendar boundaries crossed, not elapsed time. See the exact bug that inflates age and tenure, and the one-line fix.

Date FunctionsDebuggingIntermediate SQL

Two dates, three days apart, one year apart

SELECT DATEDIFF('day', DATE '2019-12-30', DATE '2020-01-02') AS days_apart,
       DATEDIFF('year', DATE '2019-12-30', DATE '2020-01-02') AS years_apart;

Three days pass. days_apart says 3, which is right. years_apart says 1, which looks wrong, since nobody would call three days "a year." Run it and you'll see both numbers are exactly what DATEDIFF is supposed to return.

Loading playground environment...

DATEDIFF doesn't measure elapsed duration. It counts how many times the calendar boundary for that unit (January 1st for years, the 1st of the month for months) gets crossed between the two dates. December 30 and January 2 sit on opposite sides of exactly one January 1st, so the year count is 1, no matter how few days separate them. This is the part every DATEDIFF tutorial explains for days and silently skips for months and years, and it's the reason "years employed" and "age" columns computed with DATEDIFF are quietly wrong for a chunk of your rows.

Same distance, different unit, opposite answer

The boundary-crossing rule produces a result that looks backwards if you haven't seen it before: a one-day gap can report a bigger month difference than a thirty-day gap.

Loading playground environment...

one_day_gap is 1: January 31 and February 1 are a single day apart, but they straddle the February 1st boundary. thirty_day_gap is 0: January 1 and January 31 are nearly a full month apart, but neither date has crossed into February, so no boundary was crossed. If your mental model is "DATEDIFF tells me how much time passed," these two results contradict each other. If your mental model is "DATEDIFF counts boundary crossings," they're both exactly correct.

Day-level DATEDIFF doesn't have this problem

DATEDIFF('day', ...) is safe from this confusion, because a day boundary is a day, so counting midnights crossed and counting elapsed days give the same number. The ambiguity only shows up once the unit is bigger than a single calendar tick: months, quarters, years.

Where this actually bites: tenure and age

This isn't just a curiosity with hand-picked dates. It shows up the moment you compute tenure from a hire date, because roughly half the rows in any real table will have a hire month-day that falls on the "wrong" side of today's date.

Loading playground environment...

Pin the reference date at June 15, 2024, and look at Paul, hired July 11, 2017: years_naive reports 7. But his next work anniversary is July 11, 2024, nearly a month after June 15. He has completed 6 full years, not 7. Carla, hired November 20, 2018, gets the same treatment: years_naive says 6, but her sixth anniversary doesn't land until November 20, 2024, so she's only 5 years in. Alice, Brian, and David, hired in March, June, and January, come out correct, because their hire month-day already fell before June 15, so their anniversary this year has already passed. The query is silently right for some rows and silently wrong for others, which is exactly why this bug survives code review: a spot check on the wrong row looks fine.

Why this passes testing

If whoever wrote the query tests it against an employee hired in January, it looks correct, because January dates rarely have an anniversary still ahead of them later that year. The bug only shows up for people hired in the back half of the year, checked before their anniversary, which is most of your near-term hires at any given moment.

The fix: subtract 1 when the anniversary hasn't happened yet

The correction is a single CASE expression: compare the month and day of the reference date against the month and day of the start date, and knock one year off if the anniversary hasn't occurred yet this year.

Loading playground environment...

STRFTIME(date, '%m%d') turns each date into a zero-padded four-character string like '0711', which compares correctly as text precisely because it's zero-padded: '0615' < '0711' behaves the same as the numeric comparison would. When the reference date's month-day is earlier than the start date's month-day, this year's anniversary is still ahead, so the CASE subtracts one. Paul and Carla drop to 6 and 5. Alice, Brian, and David, whose anniversaries had already passed, are untouched, because the naive count was already right for them. That's the same logic behind AGE() in Postgres and TIMESTAMPDIFF in MySQL when used with a YEAR unit; if your engine has one of those, prefer it over hand-rolling the CASE. If it doesn't, this is what it's doing internally.

The basics, quickly

DATEDIFF takes the unit first, then the earlier date, then the later date: DATEDIFF(unit, start, end). Swap start and end and the sign flips. For the full syntax, the supported units, and other common mistakes like unit-name spelling across engines, see the DATEDIFF reference page. The date and time functions lesson covers EXTRACT, DATE_TRUNC, and interval arithmetic, which is often a cleaner tool than DATEDIFF when you need to group rows by calendar period rather than count a difference. If you want to group orders or hires by month instead of diffing two specific dates, the GROUP BY with dates example is the pattern for that.

Where to go next

Any query with "years of service," "age," "days until renewal," or "quarters since signup" in it is worth re-checking against this rule. Run it for one row where the anniversary has passed and one where it hasn't. if the two behave differently, you've found the same bug this post walked through. The practice problems are a good place to try writing the corrected version yourself before you need it in production.

Cite this resource

SQLSimplified. "Why DATEDIFF Gives the Wrong Age in SQL". Available at: https://sqlsimplified.online/blog/why-datediff-gives-the-wrong-age-in-sql