DATE_TRUNC vs EXTRACT in SQL: Date Functions & Syntax Guide
Master DATE_TRUNC vs EXTRACT in SQL. Learn exact syntax for monthly trends, seasonality, INTERVAL rolling windows, and avoiding the BETWEEN timestamp trap.
Almost every high-value business metric depends on time: Monthly Active Users (MAU), Year-over-Year (YoY) revenue growth, 30-day retention cohorts, and daily order volume.
Yet dates are notorious for generating subtle, silent bugs. A misplaced BETWEEN clause drops an entire day's worth of transactions. A naive GROUP BY EXTRACT(MONTH...) accidentally lumps January 2022 together with January 2025. And forgetting timezone offsets reports midnight orders on the wrong calendar day.
In this guide, we master SQL date manipulation from the ground up: comparing EXTRACT vs DATE_TRUNC, performing dynamic date math with INTERVAL, avoiding the BETWEEN timestamp trap, and laying the groundwork for cohort analysis. Every query is executed against a verified PostgreSQL e-commerce dataset containing 2,000 orders spanning from January 2022 to mid-2025. For foundational query building, check our SQL CTE Guide and SQL Window Functions Guide.
1. EXTRACT: Isolating Date Components for Seasonality
The EXTRACT() function pulls out a specific numeric piece (year, month, day of week, hour) from a date or timestamp.
Syntax
EXTRACT(field FROM source)Orders Per Calendar Year
Let's group our 2,000 orders by year:
SELECT EXTRACT(YEAR FROM order_date)::int AS order_year,
COUNT(*) AS total_orders
FROM orders
GROUP BY order_year
ORDER BY order_year;The Output
| order_year | total_orders |
|---|---|
| 2022 | 625 |
| 2023 | 563 |
| 2024 | 549 |
| 2025 | 263 |
Notice that 2025 has fewer orders (263) because the dataset ends on June 1, 2025.
Analyzing Day-of-Week Seasonality
EXTRACT(DOW FROM order_date) returns the day of the week as an integer (0 for Sunday, 6 for Saturday):
SELECT EXTRACT(DOW FROM order_date)::int AS day_of_week,
COUNT(*) AS order_volume
FROM orders
WHERE status = 'completed'
GROUP BY day_of_week
ORDER BY day_of_week;EXTRACT is perfect for answering questions like: "Which day of the week has the highest purchase volume across all historical data?" However, because it discards the year, you cannot use EXTRACT alone to build a chronological timeline.
2. DATE_TRUNC: Building Continuous Trendlines
To track monthly or weekly trends over time, you must keep the year and month bound together. DATE_TRUNC() rounds a timestamp down to the beginning of the specified interval.
Truncation Examples
DATE_TRUNC('month', '2024-03-24 15:30:00'::timestamp)→2024-03-01 00:00:00DATE_TRUNC('year', '2024-03-24 15:30:00'::timestamp)→2024-01-01 00:00:00DATE_TRUNC('day', '2024-03-24 15:30:00'::timestamp)→2024-03-24 00:00:00
Monthly Revenue Timeline Query
SELECT DATE_TRUNC('month', order_date)::date AS order_month,
COUNT(*) AS completed_orders,
ROUND(SUM(total_amount), 2) AS monthly_revenue
FROM orders
WHERE status = 'completed'
GROUP BY order_month
ORDER BY order_month;The Output (First 4 Months)
| order_month | completed_orders | monthly_revenue |
|---|---|---|
| 2022-01-01 | 26 | ₹13,675.76 |
| 2022-02-01 | 24 | ₹10,370.43 |
| 2022-03-01 | 31 | ₹15,820.10 |
| 2022-04-01 | 28 | ₹12,190.50 |
Why We Add ::date
DATE_TRUNC returns a TIMESTAMP with 00:00:00. Appending ::date in PostgreSQL strips off the unnecessary midnight timestamp, returning a clean YYYY-MM-DD date formatted for reporting.
3. The BETWEEN Trap on Dates: Why Production Queries Drop Data
Filtering date ranges is where many analysts accidentally lose data. Look at this query designed to pull all orders in January 2024:
-- ⚠️ THE TEMPTING (BUT DANGEROUS) WAY
SELECT COUNT(*) AS total_orders
FROM orders
WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31';Result in our dataset: 47 orders.
Now look at the safe half-open range:
-- ✅ THE BULLETPROOF WAY: Half-Open Range
SELECT COUNT(*) AS total_orders
FROM orders
WHERE order_date >= '2024-01-01'
AND order_date < '2024-02-01';Result in our dataset: 47 orders.
Why They Match Here — And Why It Breaks in Production
In our synthetic educational dataset, every order_date is stored exactly at midnight (00:00:00). Therefore, the 31st at midnight matches both queries.
However, in production systems, timestamps contain hours, minutes, and seconds (e.g. 2024-01-31 16:45:12).
BETWEEN '2024-01-01' AND '2024-01-31'
is expanded by SQL to:
>= '2024-01-01 00:00:00' AND <= '2024-01-31 00:00:00'
Any order placed at 10:00 AM on January 31st is strictly greater than 2024-01-31 00:00:00. BETWEEN silently drops the entire last day of the month without any warning or error.
Always Use Half-Open Ranges
For date ranges on timestamps, always write:
WHERE timestamp_col >= '2024-01-01' AND timestamp_col < '2024-02-01'
Never use BETWEEN for dates.
4. Date Math with INTERVAL
SQL allows dynamic arithmetic using the INTERVAL keyword, enabling flexible rolling windows without hardcoded dates.
Rolling 90-Day Window Filter
SELECT COUNT(*) AS orders_last_90d,
ROUND(SUM(total_amount), 2) AS revenue_last_90d
FROM orders
WHERE order_date >= DATE '2025-06-01' - INTERVAL '90 days'
AND order_date < DATE '2025-06-01'
AND status = 'completed';In a production dashboard, replace the static '2025-06-01' with CURRENT_DATE:
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
AND order_date < CURRENT_DATECommon INTERVAL Expressions
INTERVAL '7 days'/INTERVAL '1 week'INTERVAL '1 month'/INTERVAL '3 months'INTERVAL '1 year'INTERVAL '2 hours 30 minutes'
5. Month-over-Month (MoM) Growth Analysis
Combining DATE_TRUNC with the LAG() window function allows you to calculate month-over-month growth in a clean, reproducible query.
WITH monthly_metrics AS (
SELECT DATE_TRUNC('month', order_date)::date AS order_month,
ROUND(SUM(total_amount), 2) AS revenue
FROM orders
WHERE status = 'completed'
GROUP BY order_month
)
SELECT order_month,
revenue,
LAG(revenue, 1) OVER (ORDER BY order_month) AS prev_month_revenue,
ROUND(revenue - LAG(revenue, 1) OVER (ORDER BY order_month), 2) AS mom_change,
ROUND(
((revenue - LAG(revenue, 1) OVER (ORDER BY order_month)) /
NULLIF(LAG(revenue, 1) OVER (ORDER BY order_month), 0)) * 100,
1
) AS mom_growth_pct
FROM monthly_metrics
ORDER BY order_month;The Output (First 3 Months)
| order_month | revenue | prev_month_revenue | mom_change | mom_growth_pct |
|---|---|---|---|---|
| 2022-01-01 | ₹13,675.76 | NULL | NULL | NULL |
| 2022-02-01 | ₹10,370.43 | ₹13,675.76 | -₹3,305.33 | -24.2% |
| 2022-03-01 | ₹15,820.10 | ₹10,370.43 | +₹5,449.67 | +52.5% |
6. Cohort Analysis Foundation: First-Order Month
In product analytics, cohort retention tracks groups of customers who signed up or made their first purchase in the same month.
Finding each customer's cohort acquisition month requires MIN(DATE_TRUNC('month', order_date)):
SELECT customer_id,
MIN(DATE_TRUNC('month', order_date))::date AS first_order_month
FROM orders
WHERE customer_id IS NOT NULL
GROUP BY customer_id
ORDER BY first_order_month, customer_id
LIMIT 10;In our dataset, the first-order cohorts start in January 2022 with 47 customers, followed by 43 customers in February 2022, forming the baseline for cohort retention curves.
7. Date Function Decision Guide
| Feature / Criteria |
|---|
8. Summary & Practice
- Use DATE_TRUNC() when preserving continuous timelines for charts, dashboards, and MoM calculations.
- Use EXTRACT() when isolating seasonal cycles across multiple years.
- Avoid BETWEEN for timestamp filtering—always use
>= start AND < next_periodto prevent losing the final day's transactions. - Cast truncated timestamps with
::datefor clean, standards-compliant date outputs.
Practice live date queries, MoM growth calculations, and cohort analyses in the Interactive SQL Practice Sandbox or build end-to-end data pipelines in our Data Analyst Career Track. To master query structure and readability, explore our SQL CTE Guide.
Practice SQL Date & Time Queries Live
Master DATE_TRUNC, EXTRACT, and rolling intervals with hands-on practice problems in our browser-based PostgreSQL sandbox.
Practice Date Functions FreeFrequently Asked Questions
What is the difference between DATE_TRUNC and EXTRACT in SQL?
DATE_TRUNC rounds a timestamp down to the start of a specified interval (e.g., month start 2024-03-01 00:00:00), preserving chronological timeline for trend analysis. EXTRACT pulls a single numeric component (e.g., month number 3 or year 2024), pooling all years together for seasonality analysis.
Why is BETWEEN dangerous for filtering timestamp ranges?
BETWEEN '2024-01-01' AND '2024-01-31' evaluates up to '2024-01-31 00:00:00'. Any transaction occurring after midnight on January 31st (e.g. 2024-01-31 14:30:00) is silently omitted. Always use the half-open range >= '2024-01-01' AND < '2024-02-01'.
Why does DATE_TRUNC need a ::date cast in PostgreSQL?
DATE_TRUNC returns a TIMESTAMP with time set to 00:00:00 (e.g., 2022-01-01 00:00:00). Appending ::date casts the result to a clean DATE type (2022-01-01), which formats cleaner in tables and BI exports.
How do you calculate rolling 30, 60, or 90 day windows in SQL?
Use INTERVAL arithmetic: WHERE order_date >= CURRENT_DATE - INTERVAL '90 days' AND order_date < CURRENT_DATE. This creates dynamic, rolling date windows without manual date recalculation.
How do you handle timezones when querying timestamps across regions?
Store timestamps in UTC using TIMESTAMP WITH TIME ZONE (TIMESTAMPTZ), and convert to the reporting timezone at query time using the AT TIME ZONE operator (e.g., order_date AT TIME ZONE 'America/New_York').

Written by
Founder at Topfolio with 6+ years in data & analytics across JPMC, Ultrahuman, and high-growth startups. Sat on hiring panels, reviewed 500+ resumes, and writes practical SQL & data guides.
Related Articles
SQL CTE (WITH Clause): Syntax, Chaining, Recursive CTEs & Real Examples (2026)
Master SQL CTEs (Common Table Expressions) using the WITH clause. Learn exact syntax, execution lifecycle, chaining CTEs, recursive CTEs for org hierarchies, and CTEs vs subqueries vs temp tables.
DDL SQL Commands: Complete Guide to Data Definition Language
Master DDL SQL commands: CREATE, ALTER, DROP, TRUNCATE, and RENAME with practical syntax, schema constraints, and DDL vs DML comparisons.
Delete Duplicate Records in SQL: 3 Proven Methods with Examples
Learn how to delete duplicate records in SQL using ROW_NUMBER() CTEs, self-joins with MIN/MAX IDs, and safe transaction workflows across dialects.