P12 · SQL & Querying

SQL dates — half-open ranges and the midnight trap

How SQL date ranges really work — and the midnight trap where BETWEEN quietly drops the last day's afternoon from your monthly report.

Start with the basics

Date and time logic looks like arithmetic, but it is full of boundaries — the edge of a day, a month, a week, a time zone — and each boundary is a place a report can quietly gain or lose rows.

The safest habit is the half-open range: include the start, and stop just before the next start. 'June' means from June 1 up to but not including July 1.

That one rule covers most of the traps. The rest come from truncating dates, time zones, and month math that clamps at the end of the month.

One that works

events
ended
2024-06-30 14:00
2024-07-01 09:00
SELECT COUNT(*) AS june
FROM events
WHERE ended >= TIMESTAMP '2024-06-01'
  AND ended <  TIMESTAMP '2024-07-01';
result
june
1

The half-open range keeps all of June 30 and excludes July 1 — one June event.

A timestamp range should be half-open — include the start, stop before the next start — so no day is cut short or double-counted.

When to use it

  • Reach for date filters whenever you report 'per day', 'this month', or 'in the last 30 days'.
  • Use a half-open range for any timestamp window: >= the start and < the next start.
  • Fill missing days with a calendar table when a report must be gap-free.

That habit — stop before the next start — is exactly what BETWEEN gets wrong on timestamps. Its upper bound lands on midnight, so the last day's afternoon falls outside the range. Here is the day that bites.

See it happen

The month that loses its last afternoon — BETWEEN on timestamps

You want June's sessions. One session ended at 2pm on June 30. Watch BETWEEN quietly drop it, because its upper bound stops at midnight.

sessions
idended
12024-06-30 14:00
22024-07-01 00:00
32024-06-10 09:00

BETWEEN — stops at midnight

SELECT id FROM sessions
WHERE ended BETWEEN TIMESTAMP '2024-06-01'
  AND TIMESTAMP '2024-06-30'
ORDER BY id;
result
id
3

half-open — keeps the whole day

SELECT id FROM sessions
WHERE ended >= TIMESTAMP '2024-06-01'
  AND ended <  TIMESTAMP '2024-07-01'
ORDER BY id;
result
id
1
3

The June report drops every session that ended after midnight on the 30th — BETWEEN's closed upper bound cuts the last day short.

Walk the full example in the unit →

The rest of the family

More ways date logic goes quietly wrong

Truncating and extracting are not the sameopen the unit →

Truncating a date to the month groups within one month; pulling out the month number merges every January across years into one bucket.

Time zones shift daily numbersopen the unit →

Storing in UTC but reporting in local time shifts 'daily' metrics by the offset, and daylight saving gives you 23- and 25-hour days.

A calendar table fills the gapsopen the unit →

Days with no data simply don't appear unless you join against a generated calendar, which is how a report becomes gap-free.

Week and month edges shift silentlyopen the unit →

Whether a week starts on Monday or Sunday, and how month math behaves at the 31st, both move boundaries without warning.

Adding months clamps at month-endopen the unit →

Adding a month to January 31 lands on February 28, and clamping doesn't reverse — adding one month twice is not the same as adding two.

An epoch number has no unitsopen the unit →

A raw epoch value could be seconds, milliseconds, or micros; the wrong assumption produces a valid timestamp in the wrong millennium, with no error.

Date differences count boundaries, not durationopen the unit →

A day-difference counts how many midnights are crossed, not elapsed time — two minutes either side of midnight counts as one day.

Self-check

How to spot it in your own SQL

  • A monthly total is slightly low and the last day looks short.
  • You filtered a timestamp range with BETWEEN.
  • A 'daily' metric shifts depending on the time zone.
  • A report is missing the days that had no data.

What people miss

Date logic reads like plain arithmetic, so the boundary bugs hide in plain sight — a report that quietly ends at midnight, a month that clamps, a time zone that shifts the day. Nothing errors; the numbers are just a little wrong at the edges.

Go deeper

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

Why does BETWEEN miss the last day of my date range?

On a timestamp column, BETWEEN's upper bound lands on midnight of the end date, so everything during that last day's daytime is excluded.

Use a half-open range instead: greater-than-or-equal to the start and strictly-less-than the day after the end.

from the unit →
Why are days with no data missing from my report?

A day with zero rows simply doesn't exist in the data, so grouping by day skips it entirely.

Join against a generated calendar (a date spine) so every day appears, with zero where there was no activity.

from the unit →
Why does adding a month to Jan 31 give Feb 28?

Month arithmetic clamps to the end of the target month when the day doesn't exist, so Jan 31 plus a month becomes Feb 28.

The clamp doesn't reverse either — adding a month twice can differ from adding two months, so be explicit about which you mean.

from the unit →
Why is my daily metric off by a day sometimes?

If timestamps are stored in UTC but you group by local day, the day boundary shifts by the time-zone offset, moving events across days.

Convert to the intended time zone before truncating to a day.

from the unit →
The P12 pattern pageRolling windows over datesSessionization and gaps