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 #
-- 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 #
Common variants #
-- 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 #
| Engine | First of month | Last of month |
|---|---|---|
| PostgreSQL / DuckDB | DATE_TRUNC('month', col) | + INTERVAL '1 month' - INTERVAL '1 day', or LAST_DAY(col) (DuckDB) |
| MySQL | DATE_FORMAT(col, '%Y-%m-01') | LAST_DAY(col) |
| SQL Server | DATEFROMPARTS(YEAR(col), MONTH(col), 1) (or DATETRUNC(MONTH, col) 2022+) | EOMONTH(col) |
| BigQuery | DATE_TRUNC(col, MONTH) | LAST_DAY(col, MONTH) |
| Snowflake | DATE_TRUNC('MONTH', col) | LAST_DAY(col, 'MONTH') |
| Oracle | TRUNC(col, 'MM') | LAST_DAY(col) |
| SQLite | date(col, 'start of month') | date(col, 'start of month', '+1 month', '-1 day') |
- Postgres / DuckDB:
DATE_TRUNC('month', d)returns aTIMESTAMP(orDATEif input isDATE).+ INTERVAL '1 month' - INTERVAL '1 day'is the canonical last-day computation. - MySQL: no
DATE_TRUNC. UseDATE_FORMAT(d, '%Y-%m-01')for first day;LAST_DAY(d)for last day. - SQL Server: no
DATE_TRUNCuntil 2022. Pre-2022, useDATEFROMPARTS(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 noLAST_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 convenientLAST_DAY(d, 'MONTH'). - Oracle:
TRUNC(d, 'MM')(no underscore) for first;LAST_DAY(d)for last. Standard SQLDATE_TRUNCnot supported. - SQLite: no native function — use
date(d, 'start of month')anddate(d, 'start of month', '+1 month', '-1 day').
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.