Intermediate

SQL Date Functions: DATE_TRUNC, EXTRACT & Intervals

Working with dates trips up almost everyone. A practical guide to the PostgreSQL date toolkit — grouping by month, date math with intervals, and time-zone-safe queries.

Dates are deceptively hard. The moment you need "revenue per month" or "users who signed up in the last 30 days", you are reaching for date functions — and the syntax differs across databases. This guide covers the PostgreSQL toolkit, which is one of the richest and most consistent. Bookmark the SQL cheat sheet for quick reference.

Current Date & Time

Start with the values the database gives you for "now". Each returns a slightly different type.

SELECT
  NOW() AS timestamp_now,       -- 2026-05-29 14:30:00+00
  CURRENT_DATE AS today,        -- 2026-05-29
  CURRENT_TIME AS time_now,     -- 14:30:00+00
  CURRENT_TIMESTAMP AS ts;      -- same as NOW()

EXTRACT — Pull Out Parts

EXTRACT pulls a single field — year, month, day, hour, day-of-week — out of a date or timestamp. It is how you answer questions like "how many orders happened on weekends?"

SELECT
  EXTRACT(YEAR  FROM created_at) AS yr,
  EXTRACT(MONTH FROM created_at) AS mon,
  EXTRACT(DOW   FROM created_at) AS day_of_week  -- 0 = Sunday
FROM orders;

-- Orders placed on a weekend
SELECT COUNT(*)
FROM orders
WHERE EXTRACT(DOW FROM created_at) IN (0, 6);

DATE_TRUNC — Group by Period

DATE_TRUNC is the single most useful date function for analytics. It rounds a timestamp down to the start of a period — month, week, day, hour — so you can GROUP BY it. This is how every "per month" chart is built.

-- Monthly revenue
SELECT
  DATE_TRUNC('month', created_at) AS month,
  SUM(total) AS revenue
FROM orders
GROUP BY month
ORDER BY month;

DATE_TRUNC('month') — DATE_TRUNC rounds each timestamp down to the start of its period, so rows from the same month collapse into one bucket you can GROUP BY.

Interval Math

You can add and subtract time using INTERVAL. This is the clean way to write "the last 30 days" or "expires in 14 days" without hard-coding dates.

-- Signups in the last 30 days
SELECT *
FROM users
WHERE created_at >= NOW() - INTERVAL '30 days';

-- Add time to a date
SELECT
  created_at,
  created_at + INTERVAL '14 days' AS trial_ends
FROM users;

AGE & Date Differences

To measure the gap between two dates, AGE gives a human-readable interval, while plain subtraction gives you days (for dates) or an interval (for timestamps).

-- Human-readable account age
SELECT name, AGE(NOW(), created_at) AS account_age
FROM users;

-- Days between two dates
SELECT order_id,
       (shipped_at::date - ordered_at::date) AS days_to_ship
FROM orders;

Formatting Dates

TO_CHAR turns a date into a formatted string for display. Use it for output only — never store or filter on formatted strings, because that throws away the date type and any index with it.

SELECT
  TO_CHAR(created_at, 'YYYY-MM-DD') AS iso_date,
  TO_CHAR(created_at, 'Mon DD, YYYY') AS pretty,   -- May 29, 2026
  TO_CHAR(created_at, 'Day') AS weekday             -- Friday
FROM orders;

Common Mistakes & Tips

  • Wrapping the date column in a function in WHERE — it blocks index use; compare against a computed bound instead
  • Using BETWEEN with timestamps — the upper bound at midnight excludes most of the last day; use >= start AND < next_day
  • Forgetting time zones — timestamptz and timestamp compare differently; be explicit
  • Filtering on TO_CHAR output — you lose the date type and the index
  • Assuming EXTRACT(DOW) starts on Monday — in PostgreSQL 0 is Sunday

Date logic pairs constantly with GROUP BY and aggregates. Put them together and practice on the banking dataset, which has real timestamped transactions.