← Guides

SQL Date Functions: Group by Month, Filter Periods and Compare Years

Monthly revenue, last-30-days filters and year-over-year comparisons in PostgreSQL, MySQL and SQL Server, with the index-killing mistake to avoid.

SQL · 8 min read

By the FormulAI editorial team · Reviewed October 2026

Group revenue by month

Each database has its own function to truncate a date. PostgreSQL uses DATE_TRUNC, MySQL uses DATE_FORMAT, and SQL Server uses DATEFROMPARTS or FORMAT. Truncating to the first day of the month keeps the result sortable as a real date.

-- PostgreSQL
SELECT DATE_TRUNC('month', created_at) AS month,
       SUM(amount) AS revenue
FROM orders
GROUP BY 1
ORDER BY 1;

-- MySQL
SELECT DATE_FORMAT(created_at, '%Y-%m-01') AS month, SUM(amount)
FROM orders GROUP BY month ORDER BY month;

Filter a period without killing the index

Writing WHERE YEAR(created_at) = 2025 applies a function to every row, so the database cannot use the index on created_at and scans the whole table. Use a half-open range instead: it is index-friendly and handles timestamps with hours correctly.

SELECT *
FROM orders
WHERE created_at >= '2025-01-01'
  AND created_at <  '2026-01-01';

Last 30 days and rolling windows

Use the current date function of your engine: CURRENT_DATE - INTERVAL '30 days' in PostgreSQL, CURDATE() - INTERVAL 30 DAY in MySQL, DATEADD(day, -30, GETDATE()) in SQL Server. Decide whether "30 days" includes today; dashboards and finance teams often disagree, and the difference shows up in totals.

Year-over-year comparison

Aggregate by month first, then use LAG with an offset of 12 to fetch the same month of the previous year. This avoids a self-join and gives the growth rate in one pass. NULLIF prevents a division by zero for new products.

WITH m AS (
  SELECT DATE_TRUNC('month', created_at) AS month, SUM(amount) AS revenue
  FROM orders GROUP BY 1
)
SELECT month, revenue,
       LAG(revenue, 12) OVER (ORDER BY month) AS revenue_last_year,
       ROUND(100.0 * (revenue - LAG(revenue, 12) OVER (ORDER BY month))
             / NULLIF(LAG(revenue, 12) OVER (ORDER BY month), 0), 1) AS growth_pct
FROM m
ORDER BY month;

Months with no sales

GROUP BY only returns months that have rows, so a month with zero sales disappears from the chart. Generate a calendar (generate_series in PostgreSQL, a recursive CTE elsewhere) and LEFT JOIN your aggregates onto it with COALESCE(revenue, 0). Time zones matter too: store timestamps in UTC and convert with AT TIME ZONE before truncating if the report is local.

How this guide was made

Every formula, query and script in this guide was run on a real sample file (Excel 365, Google Sheets, PostgreSQL, MySQL or Python 3) before publication. We document the edge cases we hit during testing, such as text-formatted numbers, empty months or NULL values. AI helps us draft and translate; a human checks each example and updates the guide when software versions change. Spotted an error? Write to contact@formulai-app.com.