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

Hàm LAG và LEAD trong SQL: lấy giá trị dòng trước và dòng sau, kèm ví dụ

Minh họa phẳng: một hàng thẻ bảng giờ bay với mũi tên chỉ ngược về thẻ trước và chỉ tới thẻ sau, trên trời có một chiếc máy bay

LAG(column) trả về giá trị của column ở dòng trước, còn LEAD(column) ở dòng sau, theo thứ tự bạn đặt bằng OVER (ORDER BY ...). Cả hai đều giữ nguyên mọi dòng trong kết quả, nên bạn có thể đặt “kỳ này” và “kỳ trước” cạnh nhau rồi trừ đi. Thêm PARTITION BY để chỉ nhìn các dòng trước hoặc sau của cùng một khách hàng, sản phẩm hay chuyến bay. Cú pháp đầy đủ là LAG(column, offset, default): offset là lùi lại bao nhiêu dòng (mặc định 1), default là giá trị trả về khi không có dòng đó (mặc định NULL).

Mọi ví dụ đều chạy trên cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto. 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. LAG và LEAD là hàm cửa sổ (window function); nếu OVER và PARTITION BY còn mới với bạn, hãy bắt đầu từ bài hàm cửa sổ trong SQL.

LAG và LEAD: dòng trước và dòng sau

Số đơn đặt chỗ mỗi ngày đầu tháng 8, kèm ngày hôm trước và ngày hôm sau trên mỗi dòng:

WITH daily AS (
  SELECT CAST(book_date AS date) AS day, COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-08-01' AND book_date < '2026-08-06'
  GROUP BY CAST(book_date AS date)
)
SELECT day, bookings,
       LAG(bookings)  OVER (ORDER BY day) AS prev_day,
       LEAD(bookings) OVER (ORDER BY day) AS next_day
FROM daily
ORDER BY day;
day bookings prev_day next_day
2026-08-01 5746 NULL 5761
2026-08-02 5761 5746 5665
2026-08-03 5665 5761 5813
2026-08-04 5813 5665 5937
2026-08-05 5937 5813 NULL

Dòng đầu tiên không có dòng trước nên LAG trả về NULL; dòng cuối không có dòng sau nên LEAD trả về NULL. Lưu ý rằng cửa sổ chỉ thấy những dòng đã qua WHERE: ngày 1/8 không có “ngày hôm trước” ở đây vì ngày 31/7 đã bị lọc đi, chứ không phải vì hôm đó không có đơn nào.

LEAD(x) giống LAG(x) với thứ tự đảo ngược: LEAD(bookings) OVER (ORDER BY day) bằng LAG(bookings) OVER (ORDER BY day DESC). Hãy dùng cách nào đọc tự nhiên hơn.

Offset và giá trị mặc định

Tham số thứ hai cho phép nhìn xa hơn về phía trước, tham số thứ ba thay cho NULL khi không có gì để nhìn:

WITH daily AS (
  SELECT CAST(book_date AS date) AS day, COUNT(*) AS bookings
  FROM bookings
  WHERE book_date >= '2026-07-01' AND book_date < '2026-08-01'
  GROUP BY CAST(book_date AS date)
)
SELECT day, bookings,
       LAG(bookings, 1, 0) OVER (ORDER BY day) AS prev_or_zero,
       LAG(bookings, 7)    OVER (ORDER BY day) AS same_day_last_week,
       ROUND(100.0 * (bookings - LAG(bookings, 7) OVER (ORDER BY day))
             / LAG(bookings, 7) OVER (ORDER BY day), 1) AS wow_pct
FROM daily
ORDER BY day
LIMIT 10;
day bookings prev_or_zero same_day_last_week wow_pct
2026-07-01 5353 0 NULL NULL
2026-07-02 5535 5353 NULL NULL
2026-07-03 5544 5535 NULL NULL
2026-07-04 5460 5544 NULL NULL
2026-07-05 5676 5460 NULL NULL
2026-07-06 5571 5676 NULL NULL
2026-07-07 5684 5571 NULL NULL
2026-07-08 5501 5684 5353 2.8
2026-07-09 5507 5501 5535 -0.5
2026-07-10 5695 5507 5544 2.7

(Hiển thị 10 ngày đầu tháng 7.)

LAG(x, 7) nghĩa là lùi bảy dòng, không phải bảy ngày. Nếu daily thiếu một ngày, phép so sánh âm thầm bị lệch. Khi có thể có ngày trống, hãy dựng lịch đầy đủ trước; xem bài date spine (tiếng Anh).

So sánh với kỳ trước

Cách dùng LAG phổ biến nhất: doanh thu tuần này so với tuần trước, theo giá trị tuyệt đối và theo phần trăm. Chỉ lấy các tuần trọn vẹn, từ 29/6 đến 9/8:

WITH weekly AS (
  SELECT CAST(date_trunc('week', book_date) AS date) AS week,
         SUM(total_amount) AS revenue
  FROM bookings
  WHERE book_date >= '2026-06-29' AND book_date < '2026-08-10'
  GROUP BY 1
)
SELECT week, revenue,
       LAG(revenue) OVER (ORDER BY week) AS prev_week,
       revenue - LAG(revenue) OVER (ORDER BY week) AS change,
       ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY week))
             / LAG(revenue) OVER (ORDER BY week), 1) AS pct_change
FROM weekly
ORDER BY week;
week revenue prev_week change pct_change
2026-06-29 29711792.00 NULL NULL NULL
2026-07-06 30884431.00 29711792.00 1172639.00 3.9
2026-07-13 30629366.00 30884431.00 -255065.00 -0.8
2026-07-20 30583420.00 30629366.00 -45946.00 -0.2
2026-07-27 31165038.00 30583420.00 581618.00 1.9
2026-08-03 33778748.00 31165038.00 2613710.00 8.4

Doanh thu tính bằng đô la Mỹ. Cửa sổ chạy sau GROUP BY, nên nó làm việc trên tổng theo tuần chứ không phải từng đơn đặt chỗ. Hai thói quen hữu ích ở đây:

Lặp lại LAG(revenue) OVER (ORDER BY week) ba lần hơi dài dòng nhưng vô hại: các cửa sổ giống hệt nhau dùng chung một lần sắp xếp. Để dễ đọc hơn, bạn cũng có thể tính prev_week trong một CTE rồi làm phép tính ở truy vấn bên ngoài. Báo cáo “tháng này so với tháng trước” (MoM) mà team kinh doanh hay hỏi cũng viết y hệt, chỉ thay 'week' bằng 'month'.

LAG với PARTITION BY

Không có PARTITION BY, cửa sổ là toàn bộ kết quả. Với một chuỗi thời gian duy nhất thì ổn, nhưng sẽ sai ngay khi các dòng thuộc về nhiều đối tượng. Dưới đây là các chuyến bay của hai thành viên, với sân bay đến trước đó tính theo cả hai cách:

WITH trips AS (
  SELECT tm.member_id, f.scheduled_departure, f.arrival_airport
  FROM ticket_members tm
  JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
  WHERE tm.member_id IN (2969, 12589)
), x AS (
  SELECT member_id, scheduled_departure,
         LAG(arrival_airport) OVER (ORDER BY member_id, scheduled_departure) AS prev_no_partition,
         LAG(arrival_airport) OVER (PARTITION BY member_id ORDER BY scheduled_departure) AS prev_partitioned,
         ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY scheduled_departure) AS n
  FROM trips
)
SELECT member_id, scheduled_departure, prev_no_partition, prev_partitioned
FROM x
WHERE n <= 2
ORDER BY member_id, scheduled_departure;
member_id scheduled_departure prev_no_partition prev_partitioned
2969 2026-07-15 09:50:00 NULL NULL
2969 2026-07-15 18:15:00 AER AER
12589 2026-07-12 09:15:00 VKO NULL
12589 2026-07-12 13:50:00 ESL ESL

Không chia nhóm, chuyến bay đầu tiên của thành viên 12589 “thừa hưởng” VKO, sân bay đến cuối cùng của thành viên 2969. Mọi khoảng thời gian tính theo cách này đều vô nghĩa ở dòng đầu tiên của từng thành viên. Với PARTITION BY member_id, dòng đầu tiên của mỗi người nhận đúng NULL.

Thời gian giữa hai sự kiện

Lấy một timestamp trừ đi LAG của chính nó, bạn được khoảng thời gian kể từ sự kiện trước. Các chuyến bay đầu tiên của thành viên 12589, kèm số giờ kể từ lần cất cánh trước:

WITH trips AS (
  SELECT tm.member_id, f.scheduled_departure, f.departure_airport, f.arrival_airport
  FROM ticket_members tm
  JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
  WHERE tm.member_id = 12589
)
SELECT scheduled_departure, departure_airport AS dep, arrival_airport AS arr,
       LAG(arrival_airport) OVER (ORDER BY scheduled_departure) AS prev_arr,
       ROUND(EXTRACT(EPOCH FROM scheduled_departure
             - LAG(scheduled_departure) OVER (ORDER BY scheduled_departure)) / 3600, 1) AS hours_since_prev
FROM trips
ORDER BY scheduled_departure
LIMIT 6;
scheduled_departure dep arr prev_arr hours_since_prev
2026-07-12 09:15:00 DME ESL NULL NULL
2026-07-12 13:50:00 ESL UFA ESL 4.6
2026-07-15 08:15:00 UFA REN UFA 66.4
2026-07-16 09:35:00 REN VKO REN 25.3
2026-07-18 09:45:00 VKO LED VKO 48.2
2026-07-19 09:45:00 LED SVO LED 24

Timestamp trừ timestamp ra một INTERVAL; EXTRACT(EPOCH FROM ...) đổi nó thành số giây, dễ lấy trung bình và so sánh hơn. Với kiểu ngày, date - date trong PostgreSQL và DuckDB đã là số ngày nguyên. Xem thêm trong bài hàm xử lý ngày tháng trong SQL.

Chuyến bay thứ hai khởi hành từ đúng nơi chuyến đầu hạ cánh (ESL) sau 4,6 giờ: đó là một chuyến nối. Đưa quy tắc này vào bộ lọc và đếm số chuyến nối theo sân bay cho mọi thành viên. Thời gian chờ là khoảng từ giờ hạ cánh dự kiến của chuyến trước đến giờ cất cánh này, và hàm cửa sổ được tính trong CTE vì nó không thể nằm trong WHERE:

WITH trips AS (
  SELECT tm.member_id, f.scheduled_departure, f.scheduled_arrival,
         f.departure_airport, f.arrival_airport
  FROM ticket_members tm
  JOIN ticket_flights tf ON tf.ticket_no = tm.ticket_no
  JOIN flights f ON f.flight_id = tf.flight_id
), legs AS (
  SELECT member_id, departure_airport,
         LAG(arrival_airport) OVER w AS prev_arr,
         EXTRACT(EPOCH FROM scheduled_departure - LAG(scheduled_arrival) OVER w) / 60 AS layover_min
  FROM trips
  WINDOW w AS (PARTITION BY member_id ORDER BY scheduled_departure)
)
SELECT departure_airport AS hub, COUNT(*) AS connections,
       MIN(layover_min) AS min_layover, ROUND(AVG(layover_min)) AS avg_layover
FROM legs
WHERE prev_arr = departure_airport AND layover_min < 360
GROUP BY departure_airport
ORDER BY connections DESC
LIMIT 5;
hub connections min_layover avg_layover
LED 4085 125 219
SVO 3055 120 188
MCX 3031 125 125
ESL 1808 185 185
CSY 644 180 180

WINDOW w AS (...) đặt tên cho một cửa sổ một lần rồi dùng lại trong nhiều hàm; PostgreSQL, DuckDB và MySQL 8 hỗ trợ, còn SQL Server có từ bản 2022.

Điều gì xảy ra tiếp theo: LEAD trên chuỗi sự kiện

LEAD trả lời câu hỏi “người dùng làm gì ngay sau X?”. Sự kiện kế tiếp sau select_fare trong ứng dụng, kèm thời gian trung vị đến sự kiện đó:

WITH e AS (
  SELECT client_id, event_time, event_name,
         LEAD(event_name) OVER w AS next_event,
         EXTRACT(EPOCH FROM LEAD(event_time) OVER w - event_time) AS secs_to_next
  FROM app_events
  WINDOW w AS (PARTITION BY client_id ORDER BY event_time, event_id)
)
SELECT COALESCE(next_event, '(none)') AS next_event,
       COUNT(*) AS n,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct,
       PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY secs_to_next) AS median_secs
FROM e
WHERE event_name = 'select_fare'
GROUP BY 1
ORDER BY n DESC;
next_event n pct median_secs
add_passenger 97471 86.4 53
(none) 7140 6.3 NULL
app_open 6616 5.9 44349.5
select_fare 762 0.7 0
push_open 399 0.4 295287
error 395 0.4 4

86,4% người dùng đi tiếp sang bước thêm hành khách, thường trong vòng một phút. (none) nghĩa là client không làm gì thêm sau sự kiện đó trong dữ liệu. Còn select_fare theo sau bởi select_fare sau 0 giây là dấu hiệu đáng ngờ: đó là sự kiện bị gửi trùng, cần được loại bỏ trước (mẫu khử trùng có trong bài ROW_NUMBER, RANK và DENSE_RANK). Chú ý cột phá hòa event_id trong thứ tự của cửa sổ: nếu hai sự kiện xảy ra cùng một giây, “sự kiện kế tiếp” sẽ bị chọn tùy ý.

Phát hiện thay đổi và chia phiên

LAG dùng được với mọi kiểu dữ liệu, không chỉ số. So sánh hạng của một thành viên với hạng trước đó để thấy mọi lần lên và xuống hạng:

SELECT member_id, tier, valid_from,
       LAG(tier)        OVER (PARTITION BY member_id ORDER BY valid_from) AS prev_tier,
       LEAD(valid_from) OVER (PARTITION BY member_id ORDER BY valid_from) AS next_change
FROM loyalty_tier_history
WHERE member_id = 12589
ORDER BY valid_from;
member_id tier valid_from prev_tier next_change
12589 basic 2026-07-01 00:00:00 NULL 2026-07-19 00:00:00
12589 silver 2026-07-19 00:00:00 basic 2026-07-23 00:00:00
12589 gold 2026-07-23 00:00:00 silver 2026-07-31 00:00:00
12589 platinum 2026-07-31 00:00:00 gold NULL

Tổng hợp trên mọi thành viên, cùng phép LAG đó cho ra bảng chuyển hạng:

WITH h AS (
  SELECT member_id, tier, valid_from,
         LAG(tier) OVER (PARTITION BY member_id ORDER BY valid_from) AS prev_tier
  FROM loyalty_tier_history
)
SELECT prev_tier, tier, COUNT(*) AS moves
FROM h
WHERE prev_tier IS NOT NULL AND prev_tier <> tier
GROUP BY prev_tier, tier
ORDER BY moves DESC;
prev_tier tier moves
basic silver 4364
silver gold 659
gold platinum 145
silver basic 22
gold silver 4

LEAD(valid_from) ở truy vấn trước là nền tảng của việc kiểm tra lịch sử: phiên bản kế tiếp phải bắt đầu đúng lúc phiên bản này kết thúc. Phép kiểm tra đó, cùng những chỗ chồng lấn nó tìm ra trong bảng này, có trong bài SCD type 2 (tiếng Anh). Cùng câu hỏi chuyển hạng cũng giải được bằng self join, và sự chênh lệch giữa hai cách cho thấy những liên kết hỏng trong dữ liệu.

Chia phiên (session). Quy tắc phổ biến: một phiên mới bắt đầu khi đã qua hơn 30 phút kể từ sự kiện trước đó của người dùng. Đánh dấu điều đó bằng LAG, rồi một phép SUM lũy kế trên các cờ sẽ đánh số các phiên:

WITH e AS (
  SELECT client_id, event_time,
         CASE WHEN event_time - LAG(event_time) OVER (PARTITION BY client_id ORDER BY event_time)
                   <= INTERVAL '30 minutes' THEN 0 ELSE 1 END AS new_session
  FROM app_events
  WHERE client_id = '00017a7b4ac7'
)
SELECT event_time, new_session,
       SUM(new_session) OVER (PARTITION BY client_id ORDER BY event_time
                              ROWS UNBOUNDED PRECEDING) AS session_no
FROM e
ORDER BY event_time
LIMIT 8;
event_time new_session session_no
2026-07-14 08:02:57 1 1
2026-07-14 08:03:07 0 1
2026-07-14 08:03:52 0 1
2026-07-14 08:04:48 0 1
2026-07-14 08:05:36 0 1
2026-07-19 09:35:03 1 2
2026-07-19 09:35:15 0 2
2026-07-19 09:35:39 0 2

Sự kiện đầu tiên có khoảng cách NULL, phép so sánh không ra true, và ELSE 1 mở phiên 1 một cách chính xác. Cùng ý tưởng “đánh cờ rồi cộng dồn” còn dùng để tìm chuỗi ngày liên tiếp và các khoảng trống giữa chúng; họ bài toán đó được trình bày trong bài gaps and islands trong SQL (tiếng Anh).

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

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

LAG và LEAD trong SQL khác nhau thế nào?

LAG trả về giá trị từ một dòng đứng trước dòng hiện tại, LEAD từ một dòng đứng sau, theo thứ tự xác định bởi OVER (ORDER BY ...). LEAD(x) OVER (ORDER BY t) bằng LAG(x) OVER (ORDER BY t DESC).

Làm sao lấy giá trị của dòng trước trong SQL?

LAG(column) OVER (ORDER BY sort_column). Thêm PARTITION BY nếu các dòng thuộc về những người dùng, sản phẩm hay tài khoản khác nhau, để dòng đầu tiên của mỗi đối tượng nhận NULL thay vì giá trị của đối tượng khác.

Làm sao tính mức thay đổi so với tháng trước?

Tổng hợp về một dòng mỗi tháng, rồi dùng value - LAG(value) OVER (ORDER BY month) cho thay đổi tuyệt đối và 100.0 * (value - LAG(value) OVER (ORDER BY month)) / LAG(value) OVER (ORDER BY month) cho phần trăm. Loại bỏ các tháng chưa trọn.

Làm sao thay NULL trong LAG?

Truyền tham số thứ ba: LAG(value, 1, 0) trả về 0 khi không có dòng trước. Hoặc bọc lại: COALESCE(LAG(value) OVER (...), 0). Khác biệt: COALESCE còn thay cả NULL được lưu sẵn ở dòng trước, còn tham số mặc định thì không.

MySQL và SQL Server có hỗ trợ LAG và LEAD không?

Có. MySQL hỗ trợ từ bản 8.0, SQL Server từ bản 2012; PostgreSQL, DuckDB, Oracle, BigQuery và Snowflake đều hỗ trợ với cùng cú pháp. Nếu công ty bạn còn chạy MySQL 5.7, ở đó không có hàm cửa sổ, và dòng trước phải lấy bằng self join hoặc truy vấn con.

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