SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 12 phút đọc

Hàm xử lý ngày tháng trong SQL: EXTRACT, date_trunc, cộng khoảng thời gian và tính số ngày giữa hai ngày

Một tờ lịch treo tường và bảng giờ khởi hành với thời gian được cắt thành ngày, tuần và tháng

Bốn nhóm hàm xử lý ngày tháng giải quyết gần như mọi việc mà người làm phân tích gặp phải. EXTRACT lấy ra một thành phần của ngày (năm, tháng, giờ, thứ trong tuần). date_trunc làm tròn một mốc thời gian xuống đầu ngày, đầu tuần hoặc đầu tháng của nó, đó là cách bạn nhóm theo kỳ. Khoảng thời gian (+ INTERVAL '2 hours') dịch một ngày tới trước hoặc lùi về sau. Phép trừ cho bạn khoảng thời gian giữa hai ngày. Nắm vững bốn thứ này là bạn dựng được phần lớn các báo cáo theo thời gian.

Mọi ví dụ ở đây đều chạy trên cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto: 33.121 chuyến bay từ 12/7 đến 10/9/2026 và 262.788 đơn đặt chỗ, với thời gian tính theo giờ Moscow. Truy vấn dùng cú pháp PostgreSQL và chạy trên DuckDB, động cơ của phòng học SQL trên Nomto; mọi bảng kết quả đều là kết quả chạy thật. Ở những chỗ PostgreSQL và DuckDB đặt tên hàm khác nhau, bài viết sẽ nói rõ.

DATE, TIMESTAMP và INTERVAL

Ba kiểu dữ liệu đảm nhận mọi việc:

Kiểu Chứa gì Ví dụ
DATE một ngày trên lịch 2026-07-12
TIMESTAMP ngày kèm giờ 2026-07-12 09:35:00
INTERVAL một độ dài thời gian 04:37:00, 1 month 24 days

Trong dữ liệu hãng hàng không, flights.scheduled_departure, flights.actual_departure và bookings.book_date là kiểu timestamp. Để biến một timestamp thành ngày, hãy ép kiểu: scheduled_departure::date (hoặc cách viết chuẩn CAST(scheduled_departure AS DATE)). Cả hai đều chạy được trong PostgreSQL và DuckDB.

Số chuyến bay mỗi ngày trong ba ngày đầu tháng 8:

SELECT scheduled_departure::date AS day,
       COUNT(*) AS flights
FROM flights
WHERE scheduled_departure >= '2026-08-01'
  AND scheduled_departure <  '2026-08-04'
GROUP BY 1
ORDER BY 1;
day flights
2026-08-01 568
2026-08-02 558
2026-08-03 536

Hãy để ý bộ lọc: “từ đầu ngày đầu tiên trở đi, trước đầu ngày sau ngày cuối cùng”. Cách này chạy đúng với timestamp mà không cần hàm nào bọc quanh cột, nhờ đó cơ sở dữ liệu còn dùng được chỉ mục. BETWEEN '2026-08-01' AND '2026-08-03' sẽ dừng ở nửa đêm đầu ngày 3/8 và âm thầm làm mất gần trọn một ngày.

EXTRACT: lấy năm, tháng, giờ hoặc thứ trong tuần

EXTRACT(part FROM value) trả về một thành phần dưới dạng số:

SELECT flight_no,
       scheduled_departure,
       EXTRACT(year  FROM scheduled_departure) AS yr,
       EXTRACT(month FROM scheduled_departure) AS mon,
       EXTRACT(day   FROM scheduled_departure) AS d,
       EXTRACT(hour  FROM scheduled_departure) AS hr,
       EXTRACT(dow   FROM scheduled_departure) AS dow
FROM flights
WHERE flight_id = 1;
flight_no scheduled_departure yr mon d hr dow
PG0405 2026-07-12 09:35:00 2026 7 12 9 0

Ngày 12/7/2026 là Chủ nhật, và dow trả về 0: trong cả PostgreSQL lẫn DuckDB, dow chạy từ 0 (Chủ nhật) đến 6 (thứ Bảy). Nếu bạn muốn thứ Hai = 1 và Chủ nhật = 7, hãy dùng isodow. Các thành phần hữu ích khác: quarter, week (số tuần theo ISO), minute và epoch (số giây kể từ năm 1970, hoặc độ dài của một khoảng thời gian tính bằng giây, xem thêm bên dưới).

Cách dùng điển hình là nhóm theo một thành phần của ngày. Những giờ cất cánh đông nhất:

SELECT EXTRACT(hour FROM scheduled_departure) AS hour,
       COUNT(*) AS flights
FROM flights
GROUP BY 1
ORDER BY flights DESC
LIMIT 5;
hour flights
11 4004
14 3313
13 3223
9 3221
10 3046

Và theo thứ trong tuần, bắt đầu từ thứ Hai:

SELECT EXTRACT(isodow FROM scheduled_departure) AS iso_dow,
       COUNT(*) AS flights
FROM flights
GROUP BY 1
ORDER BY 1;
iso_dow flights
1 4824
2 4905
3 4932
4 4950
5 3944
6 4544
7 5022

Thứ Sáu là ngày vắng nhất trong lịch bay này, Chủ nhật là ngày đông nhất. Hãy nhớ rằng khoảng thời gian này không tròn số tuần, nên một số thứ xuất hiện nhiều hơn các thứ khác một lần. Để so sánh công bằng, hãy chia cho số lần mỗi thứ xuất hiện trong khoảng đó.

PostgreSQL và DuckDB. Cả hai đều hỗ trợ EXTRACT(part FROM x) và dạng hàm date_part('part', x). PostgreSQL 14+ trả về kiểu numeric từ EXTRACT và double precision từ date_part; DuckDB trả về số nguyên. DuckDB còn có các hàm tắt như year(x), hour(x), dayname(x) và monthname(x) mà PostgreSQL không có.

date_trunc: nhóm theo ngày, tuần hoặc tháng

EXTRACT(month ...) trả về 7 cho tháng 7 của mọi năm, điều này sai khi dữ liệu của bạn trải dài hơn một năm. Để nhóm theo kỳ trên lịch, hãy làm tròn timestamp xuống đầu kỳ bằng date_trunc('unit', value):

SELECT date_trunc('month', book_date) AS month,
       COUNT(*) AS bookings,
       ROUND(SUM(total_amount)) AS revenue
FROM bookings
GROUP BY 1
ORDER BY 1;
month bookings revenue
2026-06-01 00:00:00 24952 19647411
2026-07-01 00:00:00 171646 135972835
2026-08-01 00:00:00 66190 52049563

Tháng 6 và tháng 8 ở đây là tháng không trọn vẹn: đơn đặt chỗ bắt đầu từ 17/6 và dữ liệu dừng ở 11/8. Hãy luôn kiểm tra ngày đầu và ngày cuối trước khi so sánh các kỳ.

Tuần cũng hoạt động tương tự, và date_trunc('week', ...) bắt đầu tuần từ thứ Hai trong cả PostgreSQL lẫn DuckDB:

SELECT date_trunc('week', scheduled_departure) AS week,
       COUNT(*) AS flights
FROM flights
GROUP BY 1
ORDER BY 1
LIMIT 5;
week flights
2026-07-06 00:00:00 558
2026-07-13 00:00:00 3798
2026-07-20 00:00:00 3798
2026-07-27 00:00:00 3798
2026-08-03 00:00:00 3798

Tuần đầu tiên chỉ có Chủ nhật 12/7, đó là lý do nó chỉ có 558 chuyến bay. Các đơn vị khác: 'day', 'hour', 'quarter', 'year'.

date_trunc trên một timestamp trả về một timestamp. Thêm ::date nếu bạn muốn một ngày gọn gàng trong báo cáo. Lưu ý rằng một kỳ không có dòng nào sẽ đơn giản là không xuất hiện; nếu bạn cần mọi ngày hoặc mọi tháng trong kết quả, kể cả những kỳ trống, hãy dựng một bảng lịch trước, như trong bài về date spine.

Cộng và trừ khoảng thời gian

Cộng hoặc trừ một INTERVAL để dịch chuyển một timestamp:

SELECT flight_no,
       scheduled_departure,
       scheduled_departure + INTERVAL '2 hours' AS plus_2h,
       scheduled_departure - INTERVAL '1 day'   AS minus_1d,
       CAST(scheduled_departure AS DATE) + 7    AS week_later
FROM flights
WHERE flight_id = 1;
flight_no scheduled_departure plus_2h minus_1d week_later
PG0405 2026-07-12 09:35:00 2026-07-12 11:35:00 2026-07-11 09:35:00 2026-07-19

Cộng một số nguyên vào một giá trị DATE là cộng thêm ngày, trong cả hai hệ quản trị. Với timestamp, hãy dùng interval.

Tính toán với tháng cần cẩn thận, vì các tháng dài ngắn khác nhau. Cộng một tháng vào ngày 31/1 không tràn sang tháng 3 mà dừng ở ngày cuối cùng của tháng 2:

SELECT DATE '2026-01-31' + INTERVAL '1 month' AS a;
a
2026-02-28 00:00:00

Cùng ý tưởng đó cho bạn ngày cuối tháng: đầu tháng, cộng một tháng, trừ một ngày.

SELECT d,
       date_trunc('month', d) + INTERVAL '1 month' - INTERVAL '1 day' AS month_end,
       last_day(d) AS ld
FROM (SELECT DATE '2026-02-10' AS d);
d month_end ld
2026-02-10 2026-02-28 00:00:00 2026-02-28

last_day có trong DuckDB (và MySQL) nhưng không có trong PostgreSQL; phiên bản dùng date_trunc chạy được ở mọi nơi.

Interval cũng là cách bạn viết bộ lọc “N ngày gần nhất”. Trong bộ dữ liệu này, bản chụp được thực hiện lúc 18:00 ngày 11/8/2026, nên hãy dùng mốc đó thay cho now(): WHERE book_date >= TIMESTAMP '2026-08-11 18:00' - INTERVAL '7 days'. Trên một cơ sở dữ liệu đang hoạt động, now() và current_date chạy được trong cả PostgreSQL lẫn DuckDB.

Tính số ngày và số phút giữa hai ngày

Lấy một timestamp trừ đi một timestamp khác sẽ cho ra một interval. Những lần cất cánh trễ lâu nhất:

SELECT flight_no,
       scheduled_departure,
       actual_departure,
       actual_departure - scheduled_departure AS delay,
       EXTRACT(epoch FROM actual_departure - scheduled_departure) / 60 AS delay_min
FROM flights
WHERE actual_departure IS NOT NULL
ORDER BY delay DESC
LIMIT 3;
flight_no scheduled_departure actual_departure delay delay_min
PG0589 2026-07-25 15:30:00 2026-07-25 20:07:00 04:37:00 277
PG0164 2026-07-25 15:25:00 2026-07-25 19:53:00 04:28:00 268
PG0364 2026-07-15 11:45:00 2026-07-15 16:12:00 04:27:00 267

Một interval như 04:37:00 dễ đọc, nhưng bạn không thể lấy trung bình nó thành một KPI hay so sánh nó với một con số. EXTRACT(epoch FROM interval) biến nó thành số giây; chia cho 60 để ra phút, hoặc cho 3.600 để ra giờ. Cách này chạy giống nhau trong PostgreSQL và DuckDB, nên đó là thói quen an toàn nhất. Độ trễ trung bình theo tháng:

SELECT date_trunc('month', scheduled_departure)::date AS month,
       COUNT(*) AS departed,
       ROUND(AVG(EXTRACT(epoch FROM actual_departure - scheduled_departure) / 60), 1) AS avg_delay_min
FROM flights
WHERE actual_departure IS NOT NULL
GROUP BY 1
ORDER BY 1;
month departed avg_delay_min
2026-07-01 10826 12.8
2026-08-01 5947 11.2

Để tính số ngày tròn, hãy trừ hai giá trị ngày. DATE - DATE trả về một số nguyên là số ngày, trong cả hai hệ quản trị. Hành khách đặt chỗ trước bao lâu? Mỗi dòng bên dưới là một chặng bay trong một đơn đặt chỗ:

SELECT b.book_ref,
       b.book_date,
       f.scheduled_departure,
       f.scheduled_departure::date - b.book_date::date AS days_ahead
FROM bookings b
JOIN tickets t         USING (book_ref)
JOIN ticket_flights tf USING (ticket_no)
JOIN flights f         USING (flight_id)
ORDER BY b.book_ref, f.scheduled_departure
LIMIT 5;
book_ref book_date scheduled_departure days_ahead
00000F 2026-07-01 03:12:00 2026-07-12 17:15:00 11
00000F 2026-07-01 03:12:00 2026-07-20 10:45:00 19
000012 2026-07-10 09:02:00 2026-07-24 17:05:00 14
000012 2026-07-10 09:02:00 2026-08-05 18:15:00 26
000068 2026-08-11 14:27:00 2026-08-24 10:30:00 13

Chia khoảng cách đó thành các nhóm bằng CASE, và bạn có một báo cáo về thời gian đặt chỗ trước:

SELECT CASE WHEN days < 7  THEN '0-6 days'
            WHEN days < 14 THEN '7-13 days'
            WHEN days < 30 THEN '14-29 days'
            ELSE '30+ days' END AS booked_ahead,
       COUNT(*) AS segments
FROM (
  SELECT f.scheduled_departure::date - b.book_date::date AS days
  FROM bookings b
  JOIN tickets t         USING (book_ref)
  JOIN ticket_flights tf USING (ticket_no)
  JOIN flights f         USING (flight_id)
) s
GROUP BY 1
ORDER BY MIN(days);
booked_ahead segments
0-6 days 125
7-13 days 101479
14-29 days 891131
30+ days 52991

Cách CASE chia giá trị thành nhóm được giải thích chi tiết trong bài CASE WHEN trong SQL.

date_diff, DATEDIFF và age

Mỗi hệ quản trị có những hàm tắt khác nhau, và đây là chỗ các truy vấn sao chép từ nơi khác hay bị hỏng:

Việc cần làm PostgreSQL DuckDB MySQL / SQL Server
Số ngày giữa hai ngày d2 - d1 (kiểu date) d2 - d1 hoặc date_diff('day', t1, t2) DATEDIFF(...)
Số phút giữa hai timestamp EXTRACT(epoch FROM t2 - t1) / 60 như PostgreSQL, hoặc date_diff('minute', t1, t2) TIMESTAMPDIFF / DATEDIFF(minute, ...)
Số năm, tháng và ngày giữa hai mốc age(t2, t1) age(t2, t1) không có hàm tương đương trực tiếp
Định dạng thành văn bản to_char(t, 'YYYY-MM') strftime(t, '%Y-%m') DATE_FORMAT / FORMAT

PostgreSQL không có date_diff hay DATEDIFF, còn DuckDB không có to_char. age trả về một interval có tính đến lịch: age(TIMESTAMP '2026-08-11 18:00', TIMESTAMP '2026-06-17 00:00') cho ra 1 month 24 days 18:00:00 trong DuckDB (PostgreSQL in ra 1 mon 24 days 18:00:00).

Một cái bẫy với date_diff của DuckDB: nó đếm số ranh giới bị vượt qua, không phải số đơn vị trọn vẹn đã trôi qua:

SELECT date_diff('day', TIMESTAMP '2026-07-12 23:50', TIMESTAMP '2026-07-13 00:10') AS dd;
dd
1

Hai mươi phút, nhưng là một “ngày”. Với ngày trên lịch (đặt chỗ ngày 10, bay ngày 24), đó đúng là điều bạn muốn. Với thời gian đã trôi qua (độ trễ có dài hơn một ngày không?), hãy tính interval và dùng EXTRACT(epoch ...).

Các lỗi thường gặp

Ngày tháng thường đi cùng với việc nhóm dữ liệu và tổng lũy kế; xem bài GROUP BY và HAVING trong SQL và bài về tổng lũy kế trong SQL. Cột văn bản có bộ công cụ riêng, được trình bày trong bài về hàm xử lý chuỗi SQL.

Câu hỏi thường gặp

Làm sao lấy riêng phần ngày từ một timestamp trong SQL?

Hãy ép kiểu: scheduled_departure::date hoặc CAST(scheduled_departure AS DATE). Cả hai đều chạy được trong PostgreSQL và DuckDB. date_trunc('day', ...) cũng bỏ phần giờ nhưng vẫn giữ kiểu timestamp.

EXTRACT và date_trunc khác nhau thế nào?

EXTRACT trả về một thành phần của ngày dưới dạng số (7 cho tháng 7). date_trunc trả về một timestamp đầy đủ được làm tròn xuống đầu kỳ (2026-07-01 00:00:00). Dùng EXTRACT cho câu hỏi về tính mùa vụ (“giờ nào đông nhất?”) và date_trunc cho chuỗi thời gian (“số đơn đặt chỗ mỗi tháng”).

Làm sao tính số ngày giữa hai ngày trong PostgreSQL?

Trừ hai giá trị ngày: end_date - start_date trả về một số nguyên. Nếu các cột là timestamp, hãy ép kiểu trước (t2::date - t1::date) để tính theo ngày trên lịch, hoặc dùng EXTRACT(epoch FROM t2 - t1) / 86400 để tính số ngày đã trôi qua có phần lẻ.

date_trunc(‘week’) bắt đầu tuần từ thứ Hai hay Chủ nhật?

Từ thứ Hai, trong PostgreSQL và DuckDB. Với tuần bắt đầu từ Chủ nhật, hãy dịch ngày: date_trunc('week', d + INTERVAL '1 day') - INTERVAL '1 day'.

PostgreSQL có DATEADD không?

Không. Thay vào đó hãy cộng một interval: order_date + INTERVAL '3 days', hoặc + 3 trên cột kiểu DATE.

Ngôn ngữ khác: English, Español, Português (Brasil), 简体中文