SQL BETWEEN: Inclusive on Both Ends, and the Timestamp Trap That Costs You a Day

Use SQL BETWEEN safely for inclusive numeric ranges, timestamps, NULLs, 30-day windows, reversed bounds, and collated text ranges.

Author: PracHub

Published: 8/14/2026

SQL BETWEEN: Inclusive on Both Ends, and the Timestamp Trap That Costs You a Day

August 14, 2026
16 min read
SQL BETWEEN: Inclusive on Both Ends, and the Timestamp Trap That Costs You a Day

Quick Overview

A Data Analyst guide to PostgreSQL BETWEEN and range boundaries. Seven verified walkthroughs cover inclusive endpoints, reversed bounds, the timestamp date trap, half-open windows, lifecycle labels, NULL classification, and deterministic text comparisons.

Data AnalystFree

BETWEEN is a closed-range test: both endpoints belong to the interval. That is convenient for scores and other discrete values, but the same rule can silently exclude most of an ending date when the column stores timestamps.

Treat every range as a small data contract. Name the column type, decide whether each boundary belongs, state how NULL should behave, and use a half-open interval when adjacent time windows must fit together without gaps or overlap.

BETWEEN includes both endpoints

For numeric values, amount BETWEEN 30.00 AND 55.75 is equivalent to amount >= 30.00 AND amount <= 55.75. Orders exactly at 30.00 and 55.75 therefore remain.

Input: orders

order_idcitycreated_atamountdelivered_at
1Austin2024-12-30 09:15:0042.002024-12-30 09:48:00
2Austin2024-12-31 00:00:0018.502024-12-31 00:31:00
3Boston2024-12-31 13:47:0075.25NULL
4Chicago2024-12-31 23:59:5930.002025-01-01 00:22:00
5Denver2025-01-01 08:05:0055.752025-01-01 08:40:00
SELECT
  order_id,
  amount
FROM orders
WHERE amount BETWEEN 30.00 AND 55.75
ORDER BY amount, order_id;
Row flow through an inclusive numeric range Five order rows are tested against a closed amount interval from 30 through 55.75, and three rows including both endpoint values remain. 5 order rowsamounts from 18.50 to 75.2530.00 ≤ amount ≤ 55.75both endpoints included3 output rows30.00, 42.00, 55.75
Closed numeric intervals are clear when values equal to both boundaries should qualify.

Output

order_idamount
430.00
142.00
555.75

BETWEEN does not reorder its arguments. The first expression is the lower bound and the second is the upper bound. A reversed range is tested exactly as written.

Input: orders

order_idcitycreated_atamountdelivered_at
1Austin2024-12-30 09:15:0042.002024-12-30 09:48:00
2Austin2024-12-31 00:00:0018.502024-12-31 00:31:00
3Boston2024-12-31 13:47:0075.25NULL
4Chicago2024-12-31 23:59:5930.002025-01-01 00:22:00
5Denver2025-01-01 08:05:0055.752025-01-01 08:40:00
SELECT
  COUNT(*) AS row_count
FROM orders
WHERE amount BETWEEN 55.75 AND 30.00;
Row flow for reversed BETWEEN bounds Five order rows are tested for amounts at least 55.75 and at most 30 at the same time, so no detail row qualifies and the aggregate count is zero. 5 order rowsrange test beginsamount ≥ 55.75 and ≤ 30no value can satisfy both1 aggregate rowrow_count = 0
The detail filter finds no rows, but COUNT(*) without grouping still returns one row containing zero.

Output

row_count
0

If user-supplied bounds may arrive in either order, validate them or normalize them explicitly with LEAST and GREATEST. Do not hide a reversed input unless that matches the product's contract.

A date literal can truncate a timestamp range

PostgreSQL interprets TIMESTAMP '2024-12-31' as midnight at the start of December 31. A closed range ending there includes the row exactly at midnight but excludes every later time that day. That is why this query returns only two of the four orders created across December 30 and 31.

Input: orders

order_idcitycreated_atamountdelivered_at
1Austin2024-12-30 09:15:0042.002024-12-30 09:48:00
2Austin2024-12-31 00:00:0018.502024-12-31 00:31:00
3Boston2024-12-31 13:47:0075.25NULL
4Chicago2024-12-31 23:59:5930.002025-01-01 00:22:00
5Denver2025-01-01 08:05:0055.752025-01-01 08:40:00
SELECT
  order_id,
  created_at
FROM orders
WHERE created_at BETWEEN TIMESTAMP '2024-12-30'
                     AND TIMESTAMP '2024-12-31'
ORDER BY created_at, order_id;
Row flow for an ending date cast to midnight Five order timestamps are tested from December 30 midnight through December 31 midnight inclusive; two survive and the two later December 31 rows are lost. 5 timestamp rows4 in the two calendar daysclosed at Dec 31 00:00later Dec 31 rows fail2 output rowsorders 1 and 2 only
The SQL is valid. The mistake is a boundary that does not represent the intended end of the calendar day.

Output

order_idcreated_at
12024-12-30 09:15:00
22024-12-31 00:00:00

Avoid replacing the upper bound with 23:59:59. Timestamp precision can include fractions of a second, and a hand-written last moment is easy to get wrong.

Half-open intervals cover complete time windows

For adjacent timestamp windows, use an inclusive start and an exclusive next boundary. The interval below covers all of December 30 and 31, including the last displayed second, while excluding every instant on January 1.

Input: orders

order_idcitycreated_atamountdelivered_at
1Austin2024-12-30 09:15:0042.002024-12-30 09:48:00
2Austin2024-12-31 00:00:0018.502024-12-31 00:31:00
3Boston2024-12-31 13:47:0075.25NULL
4Chicago2024-12-31 23:59:5930.002025-01-01 00:22:00
5Denver2025-01-01 08:05:0055.752025-01-01 08:40:00
SELECT
  order_id,
  created_at
FROM orders
WHERE created_at >= TIMESTAMP '2024-12-30'
  AND created_at <  TIMESTAMP '2025-01-01'
ORDER BY created_at, order_id;
Row flow through a half-open timestamp interval Five order timestamps are tested from December 30 inclusive to January 1 exclusive, keeping all four rows in the intended two-day window. 5 timestamp rowsordered in timestart ≤ time < next dayno final-second guess4 output rowsorders 1 through 4
The next window can begin at January 1 with the same boundary and neither double-count nor lose a timestamp.

Output

order_idcreated_at
12024-12-30 09:15:00
22024-12-31 00:00:00
32024-12-31 13:47:00
42024-12-31 23:59:59

The same shape clarifies a rolling lifecycle label. Define day zero as the signup date, then treat the first 30 calendar dates as [signup_date, signup_date + 30 days). Activity before signup is not labeled new by this query.

Input: users

user_idsignup_date
12025-07-25
22025-08-20

Input: activity

user_idactivity_date
12025-07-25
12025-08-23
12025-08-24
22025-08-25
SELECT
  a.user_id,
  a.activity_date,
  CASE
    WHEN a.activity_date >= u.signup_date
     AND a.activity_date <  u.signup_date + 30
    THEN 'new'
    ELSE 'old'
  END AS lifecycle
FROM activity AS a
JOIN users AS u
  ON u.user_id = a.user_id
ORDER BY a.user_id, a.activity_date;
Row flow for a 30-day half-open lifecycle label Four activity rows join to signup dates and are compared with each user's half-open 30-day window, producing three new labels and one old label. 4 activity rowsjoin each signup datesignup ≤ day < +30user-specific boundary4 labeled rows3 new, 1 old
For user 1, August 23 is the last included date and August 24 is the first excluded date.

Output

user_idactivity_datelifecycle
12025-07-25new
12025-08-23new
12025-08-24old
22025-08-25new

Use typed boundaries in the same time zone as the business rule. If created_at is timestamptz, define the reporting zone before converting calendar dates into instants. SQL order of operations helps locate range filters within a larger query.

NOT BETWEEN still excludes NULL

NOT BETWEEN negates the closed-range comparison. It does not turn NULL into an outside value: a comparison with NULL is unknown, and WHERE keeps only true rows. When missing timestamps need their own category, test IS NULL first.

Input: orders

order_idcitycreated_atamountdelivered_at
1Austin2024-12-30 09:15:0042.002024-12-30 09:48:00
2Austin2024-12-31 00:00:0018.502024-12-31 00:31:00
3Boston2024-12-31 13:47:0075.25NULL
4Chicago2024-12-31 23:59:5930.002025-01-01 00:22:00
5Denver2025-01-01 08:05:0055.752025-01-01 08:40:00
SELECT
  order_id,
  delivered_at,
  CASE
    WHEN delivered_at IS NULL THEN 'missing'
    WHEN delivered_at BETWEEN TIMESTAMP '2024-12-30'
                              AND TIMESTAMP '2025-01-01'
    THEN 'inside'
    ELSE 'outside'
  END AS window_status
FROM orders
ORDER BY order_id;
Row flow for explicit NULL and range categories Five delivery values are classified by testing null first and then a closed timestamp range, resulting in two inside, two outside, and one missing row. 5 delivery valuesone is NULLNULL, inside, outsideordered CASE rules5 classified rows2 inside, 2 outside, 1 missing
A separate missing label preserves information that a blanket outside label would erase.

Output

order_iddelivered_atwindow_status
12024-12-30 09:48:00inside
22024-12-31 00:31:00inside
3NULLmissing
42025-01-01 00:22:00outside
52025-01-01 08:40:00outside

If the requirement is specifically “outside or missing,” write it as value NOT BETWEEN low AND high OR value IS NULL. The same three-valued logic matters for other negative predicates; see SQL NOT EQUAL.

Text ranges depend on collation

Text can be compared with range operators, but the result is lexical rather than a prefix rule. Under PostgreSQL's C collation, 'Cable' and 'Camera' are greater than the one-character value 'C', so a closed 'A' to 'C' range excludes both. A half-open 'A' to 'D' range includes them.

Input: products

product_idname
1Anchor
2Blender
3Cable
4Camera
5Dock
SELECT
  product_id,
  name,
  (name COLLATE "C") BETWEEN 'A' AND 'C' AS closed_a_to_c,
  (name COLLATE "C") >= 'A'
    AND (name COLLATE "C") < 'D' AS half_open_a_to_d
FROM products
ORDER BY product_id;
Row flow through two collated text intervals Five product names are compared under C collation; the closed A-to-C interval keeps two names while the half-open A-to-D interval keeps four. 5 product namescompare full stringsC collation rangesclosed vs half-open2 true vs 4 trueboundary choice is visible
Specifying the collation makes this example reproducible; an application's text ordering policy may use a different collation.

Output

product_idnameclosed_a_to_chalf_open_a_to_d
1Anchortruetrue
2Blendertruetrue
3Cablefalsetrue
4Camerafalsetrue
5Dockfalsefalse

For a literal prefix search, encode a prefix rule instead of assuming a range has one. Review the intended collation, case handling, and character set with sample boundary values.

FAQ

Is SQL BETWEEN inclusive?

Yes. x BETWEEN low AND high includes values equal to low or high. It is equivalent to x >= low AND x <= high when the same comparison rules apply.

Should I use BETWEEN for timestamps?

Use it only when a closed timestamp interval is truly the requirement. Calendar reporting windows are often easier to reason about as timestamp >= start AND timestamp < next_boundary.

Does NOT BETWEEN include NULL?

No. If the tested value or a boundary is NULL, the comparison is unknown. A WHERE clause removes that row unless another condition, such as value IS NULL, makes the full predicate true.

Why does an end date miss rows later that day?

A date converted to a timestamp normally identifies midnight at the start of that date. As an inclusive upper bound, it does not represent the following hours. Use the next date as an exclusive bound for a complete calendar-day window.

Can BETWEEN use expressions instead of constants?

Yes. A boundary can come from a column, parameter, or expression. Compute it in the correct type and time zone, then test boundary and NULL cases explicitly. The SQL CASE guide shows how to turn those tests into labels, and the SQL practice questions provide larger exercises.


Comments (0)