Date Spines in SQL
GROUP BY can only report days that have rows. A date spine lists every day first, so the quiet days show up as 0.
A daily report built with
GROUP BY order_date
has one row per day that had an order. Days with none are not 0; they are
missing. A chart drawn from it joins June 2 straight to June 5 and hides two
empty days, and a moving average counts rows, not days. A
date spine fixes it: a list of every date in the range,
with the facts joined on.
select d.day::date as order_day,
count(o.order_id) as orders
from range(date '2024-06-01', date '2024-06-08', interval 1 day) as d(day)
left join orders as o on o.order_date = d.day::date
group by order_day
order by order_day Generating the dates
range(start, stop, interval 1 day)
is a table function: it returns one row per day, starting at
start
and stopping before stop, the same half-open range as
Lesson 7.1 · Filtering by date range.
Its values are timestamps, so cast them with
::date
before comparing them with a date column. PostgreSQL's
generate_series
does the same job but includes the stop value.
Left join, then count a column
The spine goes on the left so every day survives the join. A day with no
order gets one row of NULLs, which is why the pattern counts
o.order_id:
count(*)
would count that NULL row and report 1. A sum over no rows is NULL rather
than 0, so wrap sums in
coalesce(…, 0).
Aggregate before you join
Join a second table to the spine and the rows multiply, exactly as in inflated counts after a join:
select d.day::date as order_day,
count(o.order_id) as order_rows,
count(distinct o.order_id) as orders
from range(date '2024-06-01', date '2024-06-08', interval 1 day) as d(day)
left join orders as o on o.order_date = d.day::date
left join order_items as oi on oi.order_id = o.order_id
group by order_day
order by order_day June 1's single order has two lines, so it counts as 2 order rows. The safe shape is the one in the exercise: total the facts per day in a CTE first, then join one row per day to the spine.
For weekly and monthly spines, week starts, and spines joined to timestamps, see the date buckets and spines reference.
Schema · Garden Shop Tables · orders, order_items 2 tables
One row per order. Unshipped orders have a null shipped_date.
One row per line item within an order.
The starter totals orders and revenue for each June day that had an order.
Return every day from June 1 to June 14, 2024 instead: order_day, orders, and revenue, with 0 in both columns on
days without orders. Sort by order_day.
Run your query to inspect the result.
Expected answer
- Columns: order_day, orders, revenue.
- Rows: 14, one for every day from June 1 to June 14.
- Only 5 days have orders. The other 9 show 0 orders and 0 revenue, not NULL.
Related
Date parts, math, and truncation.
Everyday date and text functions.
One-to-many joins can inflate totals.
INNER, LEFT, RIGHT, FULL, and CROSS joins.
A missing join condition pairs every row with every row.
Equal-width bands with FLOOR, empty bands kept.