Báo cáo tháng trước và tháng sau cùng chứa một sự kiện lúc 00:00 đầu tháng. Một nguyên nhân thường gặp là cả hai truy vấn đều nhận giá trị ở hai đầu khoảng. Thêm một số mili giây để chữa có thể lại sai khi độ chính xác timestamp thay đổi.
Bài dùng fixture SQLite 3.54.0 trên Python 3.9.6 để kiểm tra điều kiện biên. Ngữ nghĩa BETWEEN và kiểu timestamp được đối chiếu thêm tài liệu PostgreSQL 17 ngày 01/11/2026; không có PostgreSQL server được chạy trong phép thử này.
1. BETWEEN nhận cả hai đầu
value BETWEEN lower AND upper nhận cả giá trị bằng lower và bằng upper. Nếu báo cáo tháng 11 dùng đầu tháng 11 và đầu tháng 12 làm hai mốc, sự kiện đúng lúc bắt đầu tháng 12 vẫn được nhận.
Một khoảng nửa mở biểu diễn bằng created_at >= start và created_at < next_start. Mốc đầu thuộc kỳ hiện tại; mốc đầu kỳ sau thuộc kỳ sau. Hai kỳ liền nhau có thể dùng cùng một ranh giới mà không cùng nhận sự kiện ở đó.
SELECT id
FROM events
WHERE created_at >= :start
AND created_at < :next_start
ORDER BY id;
:start và :next_start là tham số, không phải chuỗi để nối trực tiếp từ input người dùng. Cú pháp placeholder phụ thuộc driver. Chọn kiểu dữ liệu và timezone nhất quán với cột trước khi gắn hai giá trị vào query thật.
2. Tạo dữ liệu ở đúng ranh giới
Fixture dưới đây lưu TEXT theo cùng một dạng ISO, cùng UTC được quy ước và cùng cách sắp xếp. Nó minh họa phép so sánh trong một bộ dữ liệu kiểm soát; không chứng minh mọi chuỗi chứa offset khác nhau đều so sánh được như timestamp.
import sqlite3
db = sqlite3.connect(":memory:")
try:
db.execute("CREATE TABLE events(id INTEGER PRIMARY KEY, created_at TEXT NOT NULL)")
db.executemany("INSERT INTO events VALUES (?, ?)", [
(0, "2026-10-31 23:59:59.999999"),
(1, "2026-11-01 00:00:00.000000"),
(2, "2026-11-15 12:00:00.000000"),
(3, "2026-11-30 23:59:59.999999"),
(4, "2026-12-01 00:00:00.000000"),
])
bounds = {
"start": "2026-11-01 00:00:00.000000",
"next_start": "2026-12-01 00:00:00.000000",
}
inclusive = db.execute("""
SELECT id FROM events
WHERE created_at BETWEEN :start AND :next_start ORDER BY id
""", bounds).fetchall()
half_open = db.execute("""
SELECT id FROM events
WHERE created_at >= :start AND created_at < :next_start ORDER BY id
""", bounds).fetchall()
assert inclusive == [(1,), (2,), (3,), (4,)]
assert half_open == [(1,), (2,), (3,)]
print(inclusive)
print(half_open)
finally:
db.close()
Assertions đã chạy thành công. Sự kiện 4 là đối chứng quan trọng: nó bị loại khỏi tháng 11 bởi điều kiện < next_start, không phải bị làm tròn về ngày trước.
3. Đừng tự tạo “thời điểm cuối cùng” của tháng
Viết 23:59:59 có thể bỏ qua sự kiện có phần giây lẻ sau đó. Viết thêm .999 không bao phủ mọi độ chính xác dữ liệu. Bắt đầu kỳ kế tiếp thường là ranh giới dễ diễn đạt và dễ nối các kỳ hơn.
Tính mốc lịch theo tháng, không mặc định cộng một số ngày cố định cho mọi tháng. Khi xử lý theo ngày ở timezone có thay đổi giờ mùa hè, cũng không nên mặc định mỗi ngày lịch đều tương đương một khoảng thời lượng cố định.
4. Xác định tháng của ai và theo timezone nào
Ví dụ dùng mốc UTC tự đặt. Nếu báo cáo nghiệp vụ là tháng theo Asia/Ho_Chi_Minh, hãy xác định đầu tháng tại timezone đó, rồi chuyển hai mốc về cách biểu diễn mà database đang dùng. Không lấy tháng UTC thay thế tháng địa phương chỉ vì cột được lưu ở UTC.
PostgreSQL phân biệt timestamp có và không có timezone. Trong truy vấn thực tế, dùng tham số có kiểu phù hợp và đối chiếu cấu hình driver/session. Fixture TEXT của SQLite chưa kiểm chứng quá trình chuyển timezone hoặc bind timestamp trong PostgreSQL.
Kiểm tra kết quả
Tạo các ca đúng đầu kỳ, ngay trước đầu kỳ, ngay trước kỳ sau và đúng đầu kỳ sau. Chạy hai kỳ liên tiếp rồi kiểm tra sự kiện ở ranh giới chỉ xuất hiện một lần. Nếu dữ liệu có NULL, xác định chính sách xử lý riêng thay vì mặc định nó thuộc một khoảng.
Phép lọc thời gian đúng bắt đầu từ định nghĩa kỳ báo cáo và hai mốc có kiểu rõ ràng. Khoảng nửa mở giúp giữ định nghĩa đó ổn định khi độ chính xác phần giây thay đổi; nó không tự sửa dữ liệu hoặc timezone đã lưu sai.

