← All cheatsheets
Data Analyst · #086 · October 29, 2026 · 3 min read

Date ranges in SQL without losing a day: half-open intervals

Why '2026-09-30' means midnight, the half-open range that never loses a day, DATE_TRUNC to build the boundaries, and the time zone question hiding in every midnight: one page.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

The September report was two percent short. Nobody could find a bug. The query was BETWEEN '2026-09-01' AND '2026-09-30' on a timestamp column, and September 30 after midnight was not in it. This page is only about range boundaries: why a date is an instant, the half-open interval, DATE_TRUNC to build the edges, and the time zone hiding in every midnight. The date functions themselves are on the SQL dates sheet. The print-ready A4 PDF is at the bottom.

The problem

  • '2026-09-30' as a timestamp means 2026-09-30 00:00:00.
  • <= end_date therefore keeps the first instant of the 30th and nothing after. The 30th is not in the month.

Half-open

  • >= start: the first instant, included.
  • < '2026-10-01': an exclusive end. There is no last day to lose.
  • DATE_TRUNC plus INTERVAL: the start of the month, plus one month, is the end.

The same month, two ways

A timestamp boundary is an instant. '2026-09-30' is the first instant of the 30th, so BETWEEN keeps none of that day.

Loses September 30:

SELECT COUNT(*)
FROM orders
WHERE created
  BETWEEN '2026-09-01'
      AND '2026-09-30';
-- stops at 00:00 on the 30th

Half-open:

SELECT COUNT(*)
FROM orders
WHERE created >= '2026-09-01'
  AND created < '2026-10-01';
-- every second of September

For the current month, let the database compute the edges:

SELECT COUNT(*)
FROM orders
WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE)
  AND created_at <  DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month';

Remember

  • Time zone: midnight where? Say it in the query.
  • DATE columns: BETWEEN is fine, there is no time of day to lose.
  • Adjacent ranges: the end of one is the start of the next.

The trap: the boundary that moves

Three boundaries that look right and count the wrong rows.

You wroteWhat happensWrite instead
<= '2026-09-30'the 30th after 00:00 is gone< '2026-10-01'
'2026-09-01' on a timestamptzmidnight in UTC, not in Paris: 2 hours off'2026-09-01 00:00 Europe/Paris'
end = the last dayFebruary: 28 or 29, you compute itstart + INTERVAL '1 month'

Adjacent ranges share a boundary: the end of September is the start of October, and the same instant is counted exactly once. Sum the twelve monthly counts and you get the yearly count, which is the test that the boundaries are right.

The time zone, in one query

On a timestamptz column, a bare literal is read in the session time zone. On a server that is usually UTC, so "September" starts at 02:00 Paris time and the orders between midnight and two in the morning on the 1st fall into August. In PostgreSQL, name the zone in the literal or in the truncation:

SELECT COUNT(*)
FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-09-01 00:00 Europe/Paris'
  AND created_at <  TIMESTAMPTZ '2026-10-01 00:00 Europe/Paris';

The rule is not "always use Paris". It is: the report has a time zone, write it down, and build the boundaries in that zone.

The full page is a free print-ready A4 PDF: half-open ranges, DATE_TRUNC, the time zone.

Frequently asked questions

Why does BETWEEN drop the last day on a timestamp column?
Because a date literal compared to a timestamp becomes midnight: '2026-09-30' means 2026-09-30 00:00:00. BETWEEN includes that instant and nothing after it, so every row from the 30th after midnight is outside the range. The month ends a day early.
What is a half-open interval in SQL?
A range with an inclusive start and an exclusive end: created_at >= '2026-09-01' AND created_at < '2026-10-01'. It contains every instant of September, including all of the 30th, and nothing from October. Adjacent ranges share a boundary and count each row exactly once.
How do you get the start and end of the current month in SQL?
DATE_TRUNC('month', some_timestamp) gives the first instant of the month. Add INTERVAL '1 month' for the exclusive end. No need to know whether February has 28 or 29 days.
Does the time zone matter for a date range?
Yes. Midnight in Paris is 22:00 UTC the evening before. On a timestamptz column, a bare date literal is interpreted in the session time zone, which is often UTC on a server, so the boundary lands two hours off and rows move between days. Say the time zone in the query.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

More cheatsheets