Tier 4 · Advanced

Date Spine Construction and Zero-Fill Patterns in SQL

By Owen Middleton · Updated September 2026 · Examples run on PostgreSQL 17

What are Date Spines in SQL?

A date spine is a complete, gap-free sequence of dates that you join your fact data to, so that every period in the desired range appears in the result — even periods with no activity.

Your sales table only has rows for days with sales. Group by day and you get no row for quiet days. When a report or chart requires continuous dates with explicit zeros, the fact table alone can't provide the structure. The spine generates the dates; the LEFT JOIN attaches what fact data exists.

How do you fill missing dates with zeros in SQL?

The pattern has three fixed parts: a CTE containing the generated spine, a LEFT JOIN from the spine to the fact table, and COALESCE to convert NULL measures to zero.

WITH spine AS (
    SELECT generate_series(
        '2024-01-01'::date,
        '2024-12-31'::date,
        '1 day'::interval
    )::date AS day
)
SELECT
    s.day,
    COALESCE(SUM(o.total_amount), 0) AS daily_revenue,
    COALESCE(COUNT(o.id), 0)         AS order_count
FROM spine s
LEFT JOIN orders o ON o.ordered_at::date = s.day
GROUP BY s.day
ORDER BY s.day
WITH spine AS (
  SELECT generate_series('2024-01-01'::date, '2024-12-01'::date, interval '1 month')::date AS month
),
monthly AS (
  SELECT date_trunc('month', ordered_at)::date AS month, SUM(total_amount) AS revenue
  FROM orders GROUP BY 1
)
SELECT s.month, COALESCE(m.revenue, 0) AS revenue
FROM spine s
LEFT JOIN monthly m ON m.month = s.month
ORDER BY s.month

The spine drives the result. Every month in the spine appears regardless of whether orders exist. Months with no orders produce NULL in the revenue column after the LEFT JOIN. COALESCE converts those NULLs to zero. The LEFT JOIN to the pre-aggregated monthly CTE guarantees one row per generated month.

Why does a date spine still drop empty periods?

The one thing that trips people up

Two things can silently break this pattern:

First, the spine must be the left table. The fact table must be on the right. A regular JOIN or a fact-table-first join drops spine rows with no matches, eliminating exactly the zero-fill rows you're building the pattern to produce.

Second, the join key must match exactly. If your fact table stores timestamps (2024-03-15 14:32:00) and the spine has plain dates (2024-03-15), the join misses rows where the time component is non-zero. Truncate the fact table's timestamp in the ON clause:

LEFT JOIN orders o ON o.ordered_at::date = s.day

Or use date_trunc('day', o.ordered_at)::date = s.day for cleaner intent.

How do you build a weekly or monthly date spine?

Non-daily spines

Change the step interval for weekly, monthly, or hourly spines. For monthly spines, use '1 month'::interval starting from the first of the month — not '30 days' or '31 days'. The '1 month' step lands correctly on the first of each subsequent month regardless of month length.

Once the zero-fill is in place, aggregate window functions work correctly across the full continuous series — running totals and rolling averages no longer skip dates because every date has an explicit row.

Why does a chart need a gap-free date series?

Why this pattern matters

Most visualization tools and downstream processes expect complete time series. A chart library that receives data with missing dates will typically connect the dots across the gap, which misrepresents the data. A rolling average applied to a sparse series counts across a calendar window that contains fewer rows than expected. The date spine + LEFT JOIN + COALESCE pattern is what makes both of these work correctly. It's one of the most commonly needed structures in time-series analytics, and once you have it memorized, it takes less than a minute to write.

Practice Date Spines in SQL

Practice · easy ecommerce · Brightlane

Scenario: Brightlane's fulfillment operations team is sizing daily staffing against last year's order volume and needs a complete view of the first week of January 2024.

Task: Write a query to return each date from January 1, 2024 through January 7, 2024 alongside the number of orders placed on that date.

Assumptions:

  • The orders table holds one row per placed order, with the placement timestamp stored in ordered_at.
  • Some dates in the range have no recorded orders; those dates must still appear in the result with a count of zero.

Output:

  • One row per date in the range, including dates with no orders.
  • Columns in this order: day, order_count.
  • Sorted by day ascending.
Schema · ecommerce5 tables? = nullable
categories
idinteger
nametext
parent_id?integer
products
idinteger
nametext
category_id?integer
pricenumeric
stock_qtyinteger
attributes?jsonb
order_items
idinteger
order_id?integer
product_id?integer
quantityinteger
unit_pricenumeric
customers
idinteger
nametext
emailtext
city?text
countrytext
created_attimestamptz
is_activeboolean
orders
idinteger
customer_id?integer
ordered_attimestamptz
statustext
total_amountnumeric

Run previews · Check grades

Write a query, then run it to see results here.

Worked solution

The full breakdown walks through the shape, each clause, why this approach beats the alternatives, and the trap to avoid.

See the full worked solution

10 Date Spines practice problems

Write a query to return each date from January 1, 2024 through January 7, 2024 alongside the number of orders placed on that date.

easy ecommerce

Write a query to return each date from March 1, 2024 through March 7, 2024 alongside the number of events recorded on that date.

easy analytics

Write a query to return each date from January 1, 2024 through January 31, 2024 alongside the number of orders placed on that date.

easy ecommerce

Write a query to return each date from January 1, 2024 through January 7, 2024 alongside the total order revenue for that date.

medium ecommerce

Write a query to return each date from March 1, 2024 through March 7, 2024 alongside the number of purchase events recorded on that date.

medium analytics

Write a query to return each date from January 1, 2024 through January 7, 2024 alongside the number of orders placed on that date and the total number of orders placed from January 1, 2024 through that date inclusive.

medium ecommerce

Write a query to return each date from March 1, 2024 through March 7, 2024 alongside the number of events recorded on that date and the total number of events from March 1, 2024 through that date inclusive.

medium analytics

Write a query to return each date in the series — starting on March 15, 2024 and ending on March 1, 2024 — alongside the number of orders placed on that date.

hard ecommerce

Write a query to return every date from January 1, 2024 through January 7, 2024 on which no orders were placed.

hard ecommerce

Write a query to return each date from March 1, 2024 through March 7, 2024 alongside the number of events recorded on that date and the running average daily event count from March 1, 2024 through that date inclusive.

hard analytics

Start learning to practice all 10 Date Spines problems, with instant grading and mastery tracking.

Common questions about Date Spines

How do I show zero for days with no data in SQL?

Build the complete list of dates first, then LEFT JOIN your data onto it and wrap the aggregate in COALESCE(..., 0). The LEFT JOIN keeps every date in the list even when nothing matches, and COALESCE turns the resulting NULL into a zero.

Why does a day with no data disappear instead of showing zero?

GROUP BY can only group rows that exist. A day with no orders has no row to group, so it never reaches the result. Nothing errors and nothing warns you: the day is simply absent, which is why gaps in a daily report are easy to miss.

Does the date spine go on the left or the right of the join?

The left. A LEFT JOIN keeps every row from its left-hand table, so the spine has to be there for every date to survive. Put the spine on the right and you are keeping every order instead, and the empty days vanish again.

Why count a column instead of using COUNT(*) on a date spine?

COUNT(*) counts rows, and the LEFT JOIN still produces one row for an empty day, so COUNT(*) reports 1 for a day that had nothing. COUNT(orders.id) counts only non-NULL values, and an empty day has a NULL id, so it correctly reports 0.

How you actually get good at SQL

Reading explains SQL. Writing it, over and over with instant feedback, is what makes you fluent.

That's the whole SQLMaxx loop: 600+ real problems, instant AI feedback, mastery you can actually see, and spaced review that won't let you forget.

A stack of SQL practice problem cards, the top card showing an employees table.
615 problems · 66 concepts

Real problems. Not toy examples.

615 hand-built problems spanning all 66 concepts, from basic SELECTs to window functions, built on real schemas and real business questions, the kind you'll actually get asked on the job. Enough reps to make SQL automatic.

A retro computer showing a SQL query marked correct with a green checkmark.
Instant AI feedback

Write a query. Know if it's right in one second.

No copying an answer and hoping it clicked. The AI grader checks your real query against real data, catches exactly what's wrong, and explains the fix in plain English, like a senior analyst reading over your shoulder on every problem.

A circular mastery progress dial filling from blue to green, the SQLMaxx diamond at its center.
Mastery tracking

Stop guessing whether you actually know it.

SQLMaxx tracks every concept and shows you what you've mastered and what's still shaky. Your skills fill in one concept at a time, so 'I think I get joins' becomes something you can prove.

A SQL query editor circled by a blue return arrow with a clock, scheduled to come back for review.
Spaced review

Learn it once. Keep it for good.

Most of what you learn this week fades by next week. So when a concept comes due for review, SQLMaxx hands you a fresh problem to solve from a blank editor, not a flashcard to re-read. A research-backed spaced-repetition algorithm (FSRS) times each return for right before you'd forget, so your SQL is still there months later, when the interview or the job actually needs it.