Baza wiedzy / SQL

DATEDIFF w SQL: granice dat, wiek i różnice między silnikami

Pokazuję różnicę między granicą kalendarza a czasem trwania. Porównuję cztery silniki, poprawiam liczenie wieku i sprawdzam miesiące, zmianę czasu oraz filtr ostatnich 30 dni.

Kajo RudzińskiOpublikowano Aktualizacja 11 min czytania

Dwa bliskie punkty na osi czasu leżą po przeciwnych stronach granicy, obok pustych kart kalendarza.

Dwie minuty mogą dać wynik jednego dnia, a przejście z grudnia do stycznia wynik jednego roku. W SQL Serverze to prawidłowe działanie DATEDIFF: funkcja liczy przekroczone granice wybranej części daty. Problem zaczyna się wtedy, gdy taki wynik podpiszę jako pełny czas trwania albo wiek.

W tym poradniku rozdzielam cztery silniki i kilka różnych pytań o czas. Wszystkie dane są syntetyczne, a daty odniesienia stałe, więc wynik nie zmieni się jutro. Przykłady PostgreSQL wykonano w wersji 17.11, a SQLite w wersji 3.53.4 przez node:sqlite w Node 24.22.0. Kod SQL Server i MySQL sprawdzono z dokumentacją; tych dwóch silników nie uruchomiono w środowisku redakcyjnym. Wyników PostgreSQL ani SQLite nie traktuję jako testu innego dialektu.

Najpierw wybierz znaczenie różnicy

Zanim wpiszę nazwę funkcji, zapisuję pytanie zwykłym zdaniem. Czy liczę dni kalendarzowe pomiędzy datami? Sekundy trwania procesu? Ukończone lata od urodzin? A może liczbę zmian miesiąca między dwoma zdarzeniami? Każda z tych wielkości może być przydatna, ale nie są wymienne.

Przykładowo zdarzenie od 23:59 do 00:01 trwa dwie minuty i przechodzi przez północ. Z punktu widzenia kalendarza dotyka dwóch dat. Z punktu widzenia pełnych dób nie trwa ani jednej. Nazwa kolumny days bez dodatkowej definicji pozostawia odbiorcy zbyt wiele domysłów.

Silnik Operacja Znaczenie w tym poradniku
SQL Server DATEDIFF(datepart, start, end) Liczba przekroczonych granic
SQL Server 2016+ DATEDIFF_BIG(datepart, start, end) Granice, wynik typu bigint
MySQL 8.4 DATEDIFF(end, start) Różnica części daty w dniach
MySQL 8.4 TIMESTAMPDIFF(unit, start, end) Całkowita liczba jednostek według reguł funkcji
PostgreSQL 17 end_date - start_date Liczba dni dla typu date
PostgreSQL 17 AGE(end, start) Interwał kalendarzowy z latami i miesiącami
SQLite Różnica julianday lub unixepoch Dni albo sekundy

Ta tabela jest punktem wyboru, nie wspólną sygnaturą SQL. PostgreSQL i SQLite nie mają wbudowanego DATEDIFF odpowiadającego składni SQL Servera. Przy przenoszeniu zapytania zmieniam zarówno zapis, jak i testy oczekiwanego znaczenia.

Osobno ustalam, czy odbiorca chce różnicę dat, czy liczbę dat należących do okresu. Od 1 do 3 lutego różnica wynosi dwa dni, ale lista obejmująca oba końce zawiera trzy daty. Dodanie jedynki może realizować tę drugą definicję przy poprawnej kolejności dat. Nie dopisuję jej jednak automatycznie do każdego wyniku, zwłaszcza gdy pracuję ze znacznikami czasu albo przedziałem wyłączającym koniec.

Żadna z pokazanych operacji nie oblicza od razu dni roboczych z uwzględnieniem świąt i harmonogramu organizacji. Do takiego pytania przygotowałbym kalendarz określający, które daty są robocze, i policzył odpowiednie rekordy. Podobnie czas oczekiwania liczony tylko w godzinach działania zespołu wymaga harmonogramu. Sama zmiana jednostki z dnia na godzinę nie wprowadza do zapytania informacji o przerwach, weekendach ani lokalnych wyjątkach. Warto wyjaśnić to przed budową miary, zamiast poprawiać już opublikowane zestawienie.

SQL Server: liczenie granic kalendarza

Składnia SQL Servera przyjmuje jednostkę, początek i koniec. Jednostkę zapisuję jako słowo kluczowe, na przykład day, bez cudzysłowów. Oczekiwane wyniki poniższych czterech wyrażeń to kolejno 152, 1, 1 i 1.

-- example: sqlserver-boundaries
SELECT
    DATEDIFF(day, '20240101', '20240601') AS days,
    DATEDIFF(year, '20251231', '20260101') AS year_boundaries,
    DATEDIFF(month, '20260131', '20260201') AS month_boundaries,
    DATEDIFF(day, '2026-02-01T23:59:00', '2026-02-02T00:01:00') AS day_boundaries;

Pierwszy przykład obejmuje 2024 rok przestępny. Suma dni w styczniu, lutym, marcu, kwietniu i maju wynosi 31 + 29 + 31 + 30 + 31, czyli 152. Pozostałe wyrażenia celowo pokazują sytuacje, w których liczbę granic łatwo pomylić z czasem trwania.

Od 31 grudnia do 1 stycznia przekraczam jedną granicę roku. Od 31 stycznia do 1 lutego jedną granicę miesiąca. Od 23:59 do 00:01 jedną granicę dnia. Nie wynika z tego, że minął pełny rok, miesiąc albo doba. Definicję potwierdza dokumentacja DATEDIFF.

Przy pomiarze krótkiego procesu wybieram jednostkę odpowiadającą potrzebnej dokładności. Samo zmniejszenie jednostki nie naprawi jednak błędnego typu kolumny ani utraconych części sekund. Jeśli dane wcześniej zaokrąglono, funkcja nie odtworzy brakującej precyzji. Zakres i typ wyniku również mają znaczenie.

Wiek w SQL Serverze: sprawdź rocznicę

Dla osoby urodzonej 15 czerwca 1990 roku samo DATEDIFF(year, ...) daje 36 już 1 stycznia 2026. Przed czerwcowymi urodzinami ukończonych lat jest 35. W przykładzie obliczam liczbę granic, wyznaczam rocznicę i odejmuję jeden, jeżeli jeszcze nie nastąpiła.

-- example: sqlserver-age
DECLARE @birth date = '19900615';
DECLARE @as_of date = '20260614';
SELECT DATEDIFF(year, @birth, @as_of)
       - CASE WHEN DATEADD(year, DATEDIFF(year, @birth, @as_of), @birth) > @as_of
              THEN 1 ELSE 0 END AS completed_years;

Oczekiwany wynik dla 14 czerwca to 35. Po zmianie @as_of na 15 czerwca oczekuję 36. Używam typu date, bo w tym zadaniu liczę według dat kalendarzowych, bez godziny urodzenia. Przy dacie początkowej późniejszej niż data odniesienia taki wzór wymaga osobnej obsługi błędnych danych; nie deklaruję go jako uniwersalnego sposobu liczenia ujemnego wieku.

Dla 29 lutego trzeba ustalić regułę rocznicy w roku nieprzestępnym. Wariant oparty na DATEADD przyjmuje wtedy ostatni dzień lutego. To jawna konwencja obliczenia, którą trzeba uzgodnić z definicją wskaźnika. Jeżeli proces wymaga 1 marca, zmieniam regułę i test. Nie utożsamiam zachowania funkcji z dowolną definicją biznesową wieku lub stażu.

Pełne miesiące wymagają reguły końca miesiąca

Podobną korektę można zastosować do miesięcy, jeśli przyjmujemy rocznice wyznaczane przez DATEADD. Dla 31 stycznia i 28 lutego 2026 poniższy wariant oczekuje wyniku 1.

-- example: sqlserver-months
DECLARE @start date = '20260131';
DECLARE @finish date = '20260228';
SELECT DATEDIFF(month, @start, @finish)
       - CASE WHEN DATEADD(month, DATEDIFF(month, @start, @finish), @start) > @finish
              THEN 1 ELSE 0 END AS anniversary_months;

Powodem jest przyjęcie ostatniego dnia krótszego miesiąca jako rocznicy. Dla 15 stycznia i 14 lutego wynik byłby 0, a dla 15 stycznia i 15 lutego 1. Zakładam tutaj niepuste daty oraz koniec nie wcześniejszy od początku.

To nie jest jedyna możliwa definicja pełnego miesiąca. W dalszej części MySQL i AGE PostgreSQL dadzą 0 dla pary 31 stycznia i 28 lutego. Różnica wynika z reguły, więc porównanie samych liczb bez opisania reguły może prowadzić do błędnego zgłoszenia. Zachowanie przy skracaniu miesiąca opisuje DATEADD.

Nie dzielę liczby dni przez 30, jeżeli pytanie dotyczy miesięcy kalendarzowych. Taki iloraz może być osobnym wskaźnikiem umownym, lecz trzeba go tak nazwać. Styczeń, luty i marzec nie mają jednej wspólnej długości, a rok przestępny dodaje kolejną różnicę.

Pikselowe paski kalendarza różnej długości porównywane cyrklem

DATEDIFF_BIG: większy zakres wyniku

DATEDIFF obsługuje sekundy i milisekundy. DATEDIFF_BIG jest potrzebne, gdy liczba granic nie mieści się w typie int; nie służy do odblokowania jednostek, których zwykła funkcja rzekomo nie zna.

-- example: sqlserver-big
SELECT
    DATEDIFF(second, '2026-06-01T10:00:00', '2026-06-01T10:05:30') AS seconds,
    DATEDIFF_BIG(millisecond, '20260101', '20260131') AS milliseconds;

Oczekiwane wyniki to 330 sekund oraz 2592000000 milisekund. Pierwszą liczbę sprawdzam jako 5 × 60 + 30, drugą jako 30 × 24 × 60 × 60 × 1000. Druga przekracza maksymalną dodatnią wartość int, więc zwykłe DATEDIFF(millisecond, ...) dla tych dat przepełniłoby wynik.

DATEDIFF_BIG zwraca bigint i jest dostępne od SQL Server 2016. Nadal liczy granice i nadal ma skończony zakres, zwłaszcza przy najdrobniejszych jednostkach. Dobieram typ także w dalszych obliczeniach i w narzędziu odbierającym wynik. Szczegóły podaje dokumentacja DATEDIFF_BIG.

MySQL: DATEDIFF ma dwa argumenty

W MySQL 8.4 funkcja DATEDIFF istnieje, ale przyjmuje dwa argumenty w kolejności koniec, początek. Wykorzystuje ich części daty, pomijając godzinę. Nie dopisuję więc jednostki jako pierwszego argumentu.

-- example: mysql-days
SELECT
    DATEDIFF('2024-06-01', '2024-01-01') AS days,
    DATEDIFF('2024-01-01', '2024-06-01') AS reversed_days,
    DATEDIFF('2026-02-02 00:01:00', '2026-02-01 23:59:00') AS calendar_days;

Oczekiwane wyniki to 152, -152 oraz 1. Zamiana kolejności dat zmienia znak. W ostatniej kolumnie dwie minuty przechodzą przez północ, więc różnica części daty wynosi jeden dzień. To dobry przykład do sprawdzenia, czy raport ma liczyć kalendarz, czy czas procesu.

Dla innych jednostek MySQL udostępnia TIMESTAMPDIFF. Tutaj kolejność argumentów to jednostka, początek, koniec. Obie funkcje i ich argumenty opisuje dokumentacja MySQL 8.4.

-- example: mysql-units
SELECT
    TIMESTAMPDIFF(MINUTE, '2026-02-01 23:59:00', '2026-02-02 00:01:00') AS minutes,
    TIMESTAMPDIFF(DAY, '2026-02-01 23:59:00', '2026-02-02 00:01:00') AS full_days,
    TIMESTAMPDIFF(MONTH, '2026-01-31', '2026-02-28') AS full_months,
    TIMESTAMPDIFF(YEAR, '1990-06-15', '2026-06-14') AS completed_years;

Oczekiwane wartości wynoszą 2, 0, 0 oraz 35. Dwie minuty nie tworzą pełnej doby. Okres od 31 stycznia do 28 lutego nie daje pełnego miesiąca według reguły TIMESTAMPDIFF(MONTH, ...). Wiek przed czerwcowymi urodzinami wynosi 35. Wyniki te są oczekiwaniami wynikającymi z dokumentacji i ręcznej kontroli przykładów, bez wykonania na MySQL w tej redakcji.

PostgreSQL: odejmowanie dat i AGE

Gdy potrzebuję dni pomiędzy datami w PostgreSQL, odejmuję wartości typu date. Gdy potrzebuję kalendarzowego opisu różnicy, korzystam z AGE. W jednym zapytaniu dobrze widać różnicę typów i znaczenia.

-- example: postgres-days
SELECT
    DATE '2024-06-01' - DATE '2024-01-01' AS days,
    AGE(TIMESTAMP '2024-06-01', TIMESTAMP '2024-01-01')::text AS calendar_age;

W PostgreSQL 17.11 otrzymałem 152 oraz 5 mons. W drugim wyniku nie ma dodatkowych 31 dni. Okres od 1 stycznia do 1 czerwca obejmuje dokładnie pięć miesięcy kalendarzowych. Wykorzystanie AGE nie zamienia automatycznie tego okresu na całkowitą liczbę dni.

EXTRACT pobiera określoną część daty albo interwału. Wyciągnięcie części DAY z interwału 5 mons da 0, a nie 152. Podobnie część MONTH z interwału zawierającego dwa lata i trzy miesiące wyniesie 3. Jeśli chcę liczbę miesięcy obejmującą lata, muszę uwzględnić obie części. Typy i operacje opisuje dokumentacja PostgreSQL 17.

Wykonany przykład wieku wykorzystuje rok z wyniku AGE i stałą datę odniesienia:

-- example: postgres-age
SELECT
    EXTRACT(YEAR FROM AGE(TIMESTAMP '2026-06-14', TIMESTAMP '1990-06-15'))::int AS before_birthday,
    EXTRACT(YEAR FROM AGE(TIMESTAMP '2026-06-15', TIMESTAMP '1990-06-15'))::int AS on_birthday;

Otrzymałem odpowiednio 35 i 36. Dla 29 lutego reguła AGE nie jest identyczna z pokazanym wcześniej wariantem SQL Servera opartym na DATEADD. Kontrola dla urodzin 29 lutego 2000 daje 24 lata na 28 lutego 2025 i 25 lat na 1 marca 2025. Tę różnicę warto zachować jako jawny przypadek testowy podczas migracji.

PostgreSQL: miesiące i koniec lutego

Poniższy przykład wykonuję na trzech parach dat. Z interwału AGE pobieram lata i miesiące, pomijając pozostałe dni. Wszystkie końce są późniejsze od początków.

-- example: postgres-months
WITH spans(start_date, end_date) AS (
    VALUES (DATE '2026-01-15', DATE '2026-02-14'),
           (DATE '2026-01-15', DATE '2026-02-15'),
           (DATE '2026-01-31', DATE '2026-02-28')
), intervals AS (
    SELECT start_date, end_date,
           AGE(end_date::timestamp, start_date::timestamp) AS span
    FROM spans
)
SELECT start_date, end_date, span::text,
       (EXTRACT(YEAR FROM span) * 12 + EXTRACT(MONTH FROM span))::int AS full_months
FROM intervals
ORDER BY start_date, end_date;
Początek Koniec Interwał AGE Lata × 12 + miesiące
2026-01-15 2026-02-14 30 days 0
2026-01-15 2026-02-15 1 mon 1
2026-01-31 2026-02-28 28 days 0

Ta kontrola pokazuje, dlaczego pojedynczy przykład zaczynający i kończący się pierwszego dnia miesiąca jest za słaby. W takim przykładzie wiele różnych definicji daje tę samą liczbę. Dopiero niepełny miesiąc i ostatni dzień ujawniają różnicę.

Nie używam interwału kalendarzowego jako pomiaru czasu wykonania programu. Do czasu trwania potrzebuję znaczników początku i końca oraz jednoznacznej strefy lub przesunięcia. Lata i miesiące w opisie kalendarzowym nie mają stałej liczby sekund.

Znaczniki czasu i zmiana czasu w Warszawie

W PostgreSQL timestamp bez strefy przechowuje zapis zegara bez przypisania do konkretnego przesunięcia UTC. timestamptz reprezentuje chwilę. Porównuję lokalne północe przed i po wiosennej zmianie czasu.

-- example: postgres-dst
SELECT
    (EXTRACT(EPOCH FROM (
        TIMESTAMPTZ '2026-03-30 00:00 Europe/Warsaw'
        - TIMESTAMPTZ '2026-03-29 00:00 Europe/Warsaw'
    )) / 3600)::int AS elapsed_hours,
    (EXTRACT(EPOCH FROM (
        TIMESTAMP '2026-03-30 00:00' - TIMESTAMP '2026-03-29 00:00'
    )) / 3600)::int AS wall_clock_hours;

Wykonane zapytanie dało 23 i 24. Pierwsza wartość to rzeczywisty odstęp godzinowy między wskazanymi chwilami w Warszawie. Druga wynika z odejmowania dwóch zapisów czasu bez strefy. W dniu zmiany czasu nie można utożsamiać tych obliczeń.

Nie naprawiam tego przez bezwarunkowe dopisanie UTC do każdego napisu. Najpierw ustalam, w jakiej strefie zapisano źródło. Jeżeli lokalna godzina jest niejednoznaczna jesienią, sam napis może nie wystarczyć do odtworzenia chwili. Taki brak informacji trzeba rozwiązać przy imporcie.

Równie ważne jest rozróżnienie ostatnich 24 godzin od poprzedniego dnia lokalnego. Oba filtry bywają potrzebne, ale mają inne granice. W raporcie zapisuję strefę obok definicji okresu, żeby późniejsza zmiana ustawień sesji nie zmieniła milcząco interpretacji.

SQLite: liczby dni i sekund

SQLite udostępnia funkcje dat, ale nie ma osobnego typu daty w takim sensie jak PostgreSQL. W tych przykładach przekazuję jednoznacznie zapisane napisy. julianday pozwala policzyć różnicę w dniach, a unixepoch w sekundach.

-- example: sqlite-difference
SELECT
    julianday('2024-06-01') - julianday('2024-01-01') AS days,
    unixepoch('2026-06-01T10:05:30Z') - unixepoch('2026-06-01T10:00:00Z') AS seconds;

W SQLite 3.53.4 wynik wyniósł 152 oraz 330. Przy znacznikach z godziną różnica julianday może zawierać część ułamkową. Jest liczbą zmiennoprzecinkową, więc nie przedstawiam jej jako dowolnie precyzyjnego pomiaru. W podanych pełnych sekundach unixepoch daje wynik całkowity.

Dla warszawskiej zmiany czasu mogę przekazać jawne przesunięcia. To nie jest automatyczne rozpoznanie strefy Europe/Warsaw przez poniższą funkcję; poprawne przesunięcia są częścią przygotowanych danych.

-- example: sqlite-offsets
SELECT (
    unixepoch('2026-03-30T00:00:00+02:00')
    - unixepoch('2026-03-29T00:00:00+01:00')
) / 3600.0 AS hours;

Wynik wynosi 23. Nie zamieniam tego testu na dowód, że SQLite rozstrzygnie wszystkie historyczne reguły stref czasowych. Funkcja timediff ma inne zastosowanie: daje opis różnicy w formie tekstu. Do liczbowego czasu trwania stosuję odpowiednią różnicę wartości liczbowych. Zestaw funkcji opisuje dokumentacja SQLite.

Ostatnie 30 dni: podaj obie granice

Warunek w stylu DATEDIFF(day, order_date, teraz) <= 30 ma problem także niezależny od jednostki: przepuszcza daty przyszłe, bo ujemne wyniki również są mniejsze od 30. Zaczynam więc od jawnego przedziału i decyzji o włączeniu granic.

Poniżej w PostgreSQL wybieram ostatnie 720 godzin przed stałą chwilą odniesienia. Dolna granica jest włączona, górna wyłączona. Syntetyczne dane zawierają wartości tuż obok obu granic i jedną z przyszłości.

-- example: postgres-filter
WITH params AS (
    SELECT TIMESTAMPTZ '2026-10-21 12:00:00+00' AS as_of
), events(id, happened_at) AS (
    VALUES (1, TIMESTAMPTZ '2026-09-21 11:59:59+00'),
           (2, TIMESTAMPTZ '2026-09-21 12:00:00+00'),
           (3, TIMESTAMPTZ '2026-10-21 11:59:00+00'),
           (4, TIMESTAMPTZ '2026-10-21 12:00:00+00'),
           (5, TIMESTAMPTZ '2026-10-22 12:00:00+00')
)
SELECT id
FROM events CROSS JOIN params
WHERE happened_at >= as_of - INTERVAL '720 hours'
  AND happened_at < as_of
ORDER BY id;

Wykonany wynik zawiera tylko identyfikatory 2 i 3. Rekord 1 jest za wczesny, 4 leży dokładnie na wyłączonej granicy, a 5 jest przyszły. Przedziały o postaci [od, do) pozwalają składać sąsiednie okna bez podwójnego liczenia wspólnej granicy.

W rzeczywistym zapytaniu granice przekazuję jako parametry o odpowiednim typie. Bezpośrednie porównanie kolumny z granicami może ułatwić użycie indeksu, ale nie obiecuję konkretnego przyspieszenia bez planu wykonania i danych. Najpierw sprawdzam poprawność wyboru, potem wydajność.

NULL, odwrócone daty i testy kontrolne

Brak końca zadania nie oznacza czasu trwania równego zero. W poniższej tabeli zachowuję NULL, a opis stanu wyliczam osobno. Datę końca wcześniejszą od początku oznaczam jako niepoprawną dla tego konkretnego procesu.

-- example: postgres-null
WITH tasks(id, started, finished) AS (
    VALUES (1, DATE '2026-02-01', DATE '2026-02-03'),
           (2, DATE '2026-02-01', NULL::date),
           (3, DATE '2026-02-03', DATE '2026-02-01')
)
SELECT id, finished - started AS days,
       CASE WHEN finished IS NULL THEN 'open'
            WHEN finished < started THEN 'invalid'
            ELSE 'complete' END AS status
FROM tasks
ORDER BY id;

Wyniki to: identyfikator 1 ma 2 dni i status complete, identyfikator 2 ma NULL i open, a identyfikator 3 ma -2 oraz invalid. Nie zeruję ujemnej wartości, bo informuje o problemie, który warto zobaczyć. W innym zadaniu ujemna różnica mogłaby być poprawnym kierunkiem porównania.

Przed użyciem własnej wersji sprawdzam te same daty, odwróconą kolejność, koniec miesiąca, 29 lutego, chwilę przed rocznicą i samą rocznicę. Dla znaczników czasu dokładam zmianę strefy lub czasu oraz brak jednej wartości. Przy powtarzających się rekordach kontroluję również, czy późniejsza suma nie liczy tego samego procesu kilka razy.

Do raportu przekazuję nazwę silnika, typy kolumn, jednostkę i regułę kalendarza. Dzięki temu wynik da się odtworzyć i porównać z innym systemem. Jeśli następnym krokiem jest wyznaczenie daty po dodaniu okresu, zobacz także DATEADD.

Najważniejsze w pigułce

Czy DATEDIFF(YEAR) w SQL Serverze podaje ukończony wiek?

Nie. Liczy przekroczone granice lat i może zawyżyć wiek przed urodzinami. Potrzebna jest kontrola rocznicy, a dla 29 lutego jawna reguła roku nieprzestępnego.

Czy MySQL ma funkcję DATEDIFF?

Tak, z dwoma argumentami: koniec i początek. Liczy różnicę części daty w dniach. TIMESTAMPDIFF przyjmuje jednostkę, początek i koniec, a jego znaczenie trzeba odróżnić od granic SQL Servera.

Które przykłady wykonano we właściwym silniku?

Kod PostgreSQL wykonano w 17.11, a SQLite w 3.53.4 przez node:sqlite. Przykłady SQL Server i MySQL sprawdzono z dokumentacją bez uruchamiania tych silników; test jednego dialektu nie potwierdza drugiego.

Kajo Rudziński

Newsletter KajoData

Zostańmy w kontakcie.

Piszę o analizie danych, uczeniu się i zmianie pracy. Dzielę się tym, co pomaga mi ogarniać technologię i zawodowe decyzje. Jeśli lubisz ten sposób myślenia, zapraszam do mojego newslettera.