Tutorial

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.

Anuj SainiAug 24, 20268 min read

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

sql
EXTRACT(field FROM source)

Orders Per Calendar Year

Let's group our 2,000 orders by year:

sql
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_yeartotal_orders
2022625
2023563
2024549
2025263

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):

sql
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:00
  • DATE_TRUNC('year', '2024-03-24 15:30:00'::timestamp)2024-01-01 00:00:00
  • DATE_TRUNC('day', '2024-03-24 15:30:00'::timestamp)2024-03-24 00:00:00

Monthly Revenue Timeline Query

sql
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_monthcompleted_ordersmonthly_revenue
2022-01-0126₹13,675.76
2022-02-0124₹10,370.43
2022-03-0131₹15,820.10
2022-04-0128₹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:

sql
-- ⚠️ 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:

sql
-- ✅ 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

sql
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:

sql
WHERE order_date >= CURRENT_DATE - INTERVAL '30 days'
  AND order_date <  CURRENT_DATE

Common 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.

sql
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_monthrevenueprev_month_revenuemom_changemom_growth_pct
2022-01-01₹13,675.76NULLNULLNULL
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)):

sql
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_period to prevent losing the final day's transactions.
  • Cast truncated timestamps with ::date for 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 Free

Frequently 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').

Anuj Saini

Written by

Anuj SainiFounder & Lead Instructor

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.