Skip to course content
Free SQL course

SQL for Data Analysis and AI

Unit 08.00: Half-open ranges and the BETWEEN midnight bug

Unit ID: SQL-M08-U01 - Estimated active time: 14-17 minutes Objective: write a date range that includes every row in the period, and prove it does.

The query that loses a day

Ask for all of June:

SELECT COUNT(*) FROM orders
WHERE placed_at BETWEEN '2026-06-01' AND '2026-06-30';
-- 967

Now the same question with a half-open range:

SELECT COUNT(*) FROM orders
WHERE placed_at >= '2026-06-01' AND placed_at < '2026-07-01';
-- 1000

33 orders vanished. Every order placed on 30 June - the entire last day of the month.

Why BETWEEN did that

BETWEEN a AND b is inclusive of both endpoints, so the upper bound is literally 2026-06-30, which as a timestamp means 2026-06-30 00:00:00. Anything later that day is outside the range.

Our earliest order is at 10:00 and the latest at 21:00:

SELECT MIN(placed_at), MAX(placed_at) FROM orders;
-- 2026-06-01 10:00:00 | 2026-06-30 21:00:00

No order sits exactly at midnight, so all 33 of the final day's orders were excluded:

SELECT COUNT(*) FROM orders
WHERE placed_at >= '2026-06-30' AND placed_at < '2026-07-01';
-- 33

967 + 33 = 1,000. The arithmetic confirms exactly what was lost.

The pattern that always works

-- fragment: the range pattern, with placeholders
WHERE placed_at >= '<start of period>'
  AND placed_at <  '<start of NEXT period>'

Include the lower bound, exclude the upper. It is correct for dates and timestamps, at any precision, in every engine. You never have to reason about whether the column has a time component.

Why "just use 23:59:59" is not a fix

-- fragment: WHERE clause shown on its own
WHERE placed_at BETWEEN '2026-06-01' AND '2026-06-30 23:59:59'

This drops anything between 23:59:59.001 and midnight. With second precision you will probably never notice; with millisecond or microsecond timestamps you will, occasionally, and only in the busiest hour. The half-open range has no such edge.

Practice

Write a query counting orders in the second half of June, and state the two boundary values you used and why.

Check your answer
SELECT COUNT(*) FROM orders
WHERE placed_at >= '2026-06-16' AND placed_at < '2026-07-01';
-- 495

Lower bound 2026-06-16 inclusive - the first moment of the 16th. Upper bound 2026-07-01 exclusive - the first moment of the next month, so all of 30 June is included. Using BETWEEN … '2026-06-30' here would report 462 and lose the same 33 orders.

Takeaway

Use >= start AND < next_start. BETWEEN on a timestamp column silently truncates the final day to a single instant.

---