Historical Context & Motivation
The need to filter records by date is as old as record-keeping itself, but the formalization of date range filtering in relational databases arose from the convergence of temporal data modeling and the structured query paradigm. Early hierarchical and network databases of the 1960s stored dates as raw integers or packed character strings, requiring application-layer logic to interpret and compare temporal values. It was not until E.F. Codd's relational model gained traction that the notion of declarative filtering — specifying what data to retrieve rather than how to navigate to it — made temporal predicates a first-class concern in query language design.
Throughout this evolution, a central question persisted: how should a query engine let analysts express temporal boundaries — ranging from simple calendar-date comparisons to complex sliding windows — with both precision and clarity? Date range filtering addresses exactly this question, and understanding its conceptual underpinnings is essential before diving into vendor-specific syntax.
Core Principles & Definitions
Date range filtering rests on a small set of foundational ideas that generalize across all SQL dialects. Understanding these principles lets you reason about temporal queries abstractly before worrying about whether your engine supports DATE_TRUNC or DATEADD. The concepts below form the backbone of every temporal filter you will ever write.
Closed vs. Half-Open Intervals
Granularity & Precision
Inclusive BETWEEN Semantics
BETWEEN operator is inclusive on both ends (a closed interval). When used with TIMESTAMP columns, this can unintentionally include an entire extra day if the upper bound is a midnight boundary.Sargability
Time Zones & UTC Normalization
Visual Explanation — The Timeline Model
The diagram below illustrates how different SQL predicates map onto a continuous timeline. Each row represents a different filtering technique, and the shaded region shows which portion of the timeline is selected. Notice how the half-open interval pattern (row 3) avoids the boundary ambiguity present in the inclusive BETWEEN approach (row 2).
The visual distinction between the closed circle and the open circle in rows 2 and 3 encodes a concept that has real consequences in production SQL. When a TIMESTAMP column stores values like 2024-02-15 00:00:00, an inclusive upper bound (BETWEEN) will match records at exactly midnight on the boundary date, while a half-open predicate (< '2024-02-15') will exclude them. This seemingly minor detail can shift aggregation results by an entire day's worth of transactions.
How It Works — Predicate Patterns & Date Arithmetic
At the execution level, every date range filter reduces to one or two comparison predicates that the query optimizer evaluates against an index or during a sequential scan. The key is to express your intent using patterns that the optimizer can translate into efficient index seeks. Below are the canonical predicate patterns, each annotated with its interval semantics.
a and b are included. Safe when col is of type DATE (day precision). Risky when col is TIMESTAMP with sub-day precision.a, excludes b. Immune to precision mismatches. Consecutive ranges [a, b) ∪ [b, c) cover the timeline with no gaps.N is the lookback period. The half-open upper bound ensures today's partial data is excluded unless desired.WHERE YEAR(order_date) = 2024 — forces a full table scan because the optimizer cannot seek into a B-tree index on the transformed result. The sargable equivalent is WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01', which leverages the index directly. Always keep the column "bare" on one side of the comparison.Date arithmetic varies by dialect — PostgreSQL uses INTERVAL syntax, SQL Server uses DATEADD, and MySQL accepts both DATE_ADD and INTERVAL expressions — but the conceptual model is identical: you compute a boundary value and then compare the column against it.
Detailed Breakdown — Common Filtering Patterns
In practice, date range filters fall into a handful of recurring patterns. The diagram below classifies them by whether the boundaries are absolute (hard-coded dates) or relative (computed from the current date), and whether the window is fixed-width or variable. Understanding this taxonomy lets you select the right pattern before writing a single line of SQL.
| Pattern | Boundary Type | Typical Use Case | Sargable? |
|---|---|---|---|
| Fixed calendar period | Absolute | Quarterly financial report | ✓ Yes |
| Ad-hoc range (parameterized) | Absolute | User-driven dashboard filter | ✓ Yes |
| Rolling window | Relative | Last-30-day active users | ✓ Yes |
| Fiscal / logical period | Relative (computed) | Custom fiscal-year reporting | ✓ If boundaries are pre-computed |
| Function-wrapped column | N/A | Anti-pattern (e.g., YEAR(col) = 2024) | ✗ No — forces full scan |
Worked Example — Monthly Sales Report
Suppose you have an orders table with a placed_at TIMESTAMP column (stored in UTC) and a total_usd DECIMAL(10,2) column. Your task is to compute total revenue for February 2024, handling the leap year correctly, while ensuring the query is sargable and immune to time-of-day boundary issues.
2024-02-01 00:00:00 UTC (inclusive). Because 2024 is a leap year, the month ends on February 29, and the exclusive upper bound is 2024-03-01 00:00:00 UTC. Using the next month's first instant as the upper bound means we never have to think about 23:59:59.999999.placed_at column bare on the left side of both comparisons, enabling the optimizer to seek into a B-tree index on placed_at.WHERE placed_at >= '2024-02-01' AND placed_at < '2024-03-01'TIMESTAMP '2024-02-01'.SELECT SUM(total_usd) AS feb_revenue FROM orders WHERE placed_at >= '2024-02-01' AND placed_at < '2024-03-01';2024-02-29 23:59:59.999999 is less than '2024-03-01' and is correctly included. An order at exactly 2024-03-01 00:00:00.000000 is not less than the upper bound and is correctly excluded. The leap day is handled automatically because we specified the boundary as a date rather than manually counting days.WHERE placed_at >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL '1 month') AND placed_at < DATE_TRUNC('month', CURRENT_DATE). The DATE_TRUNC function snaps a timestamp to the start of the specified unit, yielding the correct first-of-month boundary regardless of how many days the month contains.Strengths, Limitations & Common Pitfalls
Date range filtering is deceptively simple in concept but fraught with subtle pitfalls in practice. The table below contrasts the strengths of each approach with the limitations and failure modes that trip up even experienced engineers.
| Approach | Strengths | Limitations / Pitfalls |
|---|---|---|
| BETWEEN | Concise syntax; readable for DATE columns; widely supported. | Inclusive upper bound catches midnight timestamps; developers must remember to subtract one day or one microsecond for TIMESTAMP columns. |
| Half-open (>=, <) | Precision-safe; partitions timeline without gaps; sargable. | Slightly more verbose; upper bound must be the "next" boundary, which can be non-obvious for irregular periods (e.g., fiscal quarters). |
| EXTRACT / YEAR() | Highly readable; maps directly to business language ("all January orders"). | Non-sargable — destroys index utilization; can be orders of magnitude slower on large tables. |
| Rolling INTERVAL | No hard-coded dates; self-updating; great for dashboards and alerts. | Results change on every execution — not reproducible without snapshotting the execution timestamp. Time-zone shifts can cause unexpected behavior. |
| DATE_TRUNC + range | Combines relative logic with precise boundaries; handles variable-length months. | Vendor-specific syntax (DATE_TRUNC vs. DATEADD/DATEDIFF); may need explicit casting for cross-type comparisons. |
Connection to Advanced Temporal Querying
Date range filtering is the gateway to a rich family of temporal techniques in modern SQL. Once you are comfortable with static range predicates, the next conceptual leap is to window functions over ordered time series, temporal joins that correlate events within overlapping intervals, and system-versioned temporal tables that track the full history of row mutations. The table below maps the basic concepts covered in this lesson to their advanced counterparts.
| Basic Concept (This Lesson) | Advanced Extension | Key Addition |
|---|---|---|
| WHERE col >= a AND col < b | RANGE BETWEEN in window frames | Sliding aggregation without self-join; window defined by INTERVAL offset |
| Rolling INTERVAL predicates | LAG / LEAD analytics | Access prior/next row values for period-over-period comparisons |
| Filtering on a single TIMESTAMP column | Temporal joins (OVERLAPS, Allen's intervals) | Join two tables on overlapping validity periods, not just equality |
| Static snapshot of current data | System-versioned temporal tables (SQL:2011) | Automatic history tracking; FOR SYSTEM_TIME AS OF / BETWEEN queries |
| DATE_TRUNC for boundary alignment | Time-series databases & partitioning | Physical storage optimized for time-ordered range scans (e.g., TimescaleDB hypertables) |
As data volumes grow and temporal queries become more complex, understanding the conceptual foundations laid in this lesson becomes increasingly valuable. The half-open interval convention, sargability awareness, and UTC normalization discipline carry over directly to distributed SQL engines like BigQuery, Snowflake, and Apache Spark SQL, where partition pruning on date columns is often the single most impactful optimization available.
Practice Problems
WHERE placed_at BETWEEN '2024-03-01' AND '2024-03-31' might exclude some March 31 records when placed_at is a TIMESTAMP column. What interval convention avoids this problem?SELECT * FROM events WHERE EXTRACT(YEAR FROM event_time) = 2023 AND EXTRACT(MONTH FROM event_time) = 7;DATE_TRUNC for the grouping and ensure the WHERE clause is sargable.logins(user_id INT, login_at TIMESTAMPTZ) in UTC. The product team needs a query that returns users who logged in during "business hours" (9 AM – 5 PM) in the US Eastern time zone on any day in the week of 2024-04-15. Write the query and explain why time zone handling matters here.WHERE CAST(placed_at AS DATE) = '2024-06-15' is "cleaner" than the half-open interval WHERE placed_at >= '2024-06-15' AND placed_at < '2024-06-16'. Provide a rigorous argument for or against this claim, considering correctness, performance, and portability across SQL dialects.Summary — Date Range Filtering
Date range filtering is the practice of selecting rows whose temporal column falls within a specified interval. The most robust pattern is the half-open interval — WHERE col >= start AND col < end — which avoids the precision mismatches inherent in BETWEEN when applied to TIMESTAMP columns. Boundaries may be absolute (hard-coded dates for reproducible reports) or relative (computed from CURRENT_DATE or NOW() for rolling dashboards).
A critical performance consideration is sargability: keep the date column bare in the predicate so the optimizer can leverage B-tree indexes. Wrapping the column in functions like YEAR() or CAST() prevents index seeks and forces full scans. Finally, always store and compare timestamps in UTC, converting to local time zones at the presentation layer. These principles — half-open intervals, sargable predicates, and UTC normalization — form the foundation for all advanced temporal querying, from window functions to system-versioned temporal tables.