Recipe

First day and last day of month

Short answer: for the first day of a month, DATE_TRUNC('month', date_col) works on Postgres / DuckDB / Snowflake / Redshift / Databricks. BigQuery uses the same idea with arguments swapped (DATE_TRUNC(date_col, MONTH)); SQL Server 2022+ uses DATETRUNC(MONTH, date_col). For the last day, add one month and subtract one day: DATE_TRUNC('month', date_col) + INTERVAL '1 month' - INTERVAL '1 day'. Several engines also have a LAST_DAY() shortcut (and SQL Server has EOMONTH). The pattern generalizes to "first / last day of week / quarter / year": same idea, different unit.

Canonical SQL #

sql
-- First day of the month containing date_col.
SELECT DATE_TRUNC('month', order_date) AS first_day FROM orders;       -- Postgres / DuckDB / Snowflake / Redshift / Databricks
SELECT DATE_TRUNC(order_date, MONTH)   AS first_day FROM orders;       -- BigQuery (note arg order)
SELECT DATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1) AS first_day FROM orders; -- SQL Server

-- Last day of the month containing date_col.
SELECT (DATE_TRUNC('month', order_date) + INTERVAL '1 month' - INTERVAL '1 day') AS last_day
FROM orders;                                                            -- Postgres / DuckDB

SELECT LAST_DAY(order_date) AS last_day FROM orders;                   -- MySQL / Oracle / Snowflake / DuckDB
SELECT LAST_DAY(order_date, MONTH) FROM orders;                        -- Snowflake / BigQuery (with unit)
SELECT EOMONTH(order_date) AS last_day FROM orders;                    -- SQL Server (since 2012)

Example #

Loading SQL editor...

Common variants #

sql
-- First day of last month.
SELECT DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month' AS first_of_last_month;

-- Last day of last month (= day before first of this month).
SELECT DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 day'   AS last_of_last_month;

-- Group orders by month and align the bucket label to the first of that month.
SELECT DATE_TRUNC('month', order_date) AS month_start, SUM(total) AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', order_date)
ORDER BY month_start;

-- Filter "this month so far" without inequality on a function.
SELECT * FROM orders
WHERE order_date >= DATE_TRUNC('month', CURRENT_DATE)
  AND order_date <  DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month';

Dialect comparison #

EngineFirst of monthLast of month
PostgreSQL / DuckDBDATE_TRUNC('month', col)+ INTERVAL '1 month' - INTERVAL '1 day', or LAST_DAY(col) (DuckDB)
MySQLDATE_FORMAT(col, '%Y-%m-01')LAST_DAY(col)
SQL ServerDATEFROMPARTS(YEAR(col), MONTH(col), 1) (or DATETRUNC(MONTH, col) 2022+)EOMONTH(col)
BigQueryDATE_TRUNC(col, MONTH)LAST_DAY(col, MONTH)
SnowflakeDATE_TRUNC('MONTH', col)LAST_DAY(col, 'MONTH')
OracleTRUNC(col, 'MM')LAST_DAY(col)
SQLitedate(col, 'start of month')date(col, 'start of month', '+1 month', '-1 day')
  • Postgres / DuckDB: DATE_TRUNC('month', d) returns a TIMESTAMP (or DATE if input is DATE). + INTERVAL '1 month' - INTERVAL '1 day' is the canonical last-day computation.
  • MySQL: no DATE_TRUNC. Use DATE_FORMAT(d, '%Y-%m-01') for first day; LAST_DAY(d) for last day.
  • SQL Server: no DATE_TRUNC until 2022. Pre-2022, use DATEFROMPARTS(YEAR(d), MONTH(d), 1) for first day; EOMONTH(d) for last day. 2022+: DATETRUNC(MONTH, d) for first day; EOMONTH(d) is still the last-day function (SQL Server has no LAST_DAY).
  • BigQuery: arg order is DATE_TRUNC(d, MONTH) — backwards from Postgres. LAST_DAY(d, MONTH) for last day.
  • Snowflake: standard DATE_TRUNC('MONTH', d) plus convenient LAST_DAY(d, 'MONTH').
  • Oracle: TRUNC(d, 'MM') (no underscore) for first; LAST_DAY(d) for last. Standard SQL DATE_TRUNC not supported.
  • SQLite: no native function — use date(d, 'start of month') and date(d, 'start of month', '+1 month', '-1 day').
Tip

For period-bucketing, prefer DATE_TRUNC over computing month boundaries with arithmetic. GROUP BY DATE_TRUNC('month', order_date) is one expression and self-documenting. The "first day / last day" pattern is for labeling a period (showing "Apr 1 – Apr 30 2026" in a report) or for half-open range filters (>= first_of_month AND < first_of_next_month). That range form keeps the order_date index usable, while MONTH(order_date) = 4 does not. (Note: GROUP BY on an expression is not itself index-friendly without an expression index like CREATE INDEX ON orders (DATE_TRUNC('month', order_date)); the win is the half-open range filter.) Reference: DATE_TRUNC.