Find Missing Dates in SQL: Gap Detection and Filling (PostgreSQL, MySQL, BigQuery)
Find gaps in date sequences — and fill them with zero rows — using generate_series, recursive CTEs, and calendar tables. Works in PostgreSQL, MySQL 8, and BigQuery.
The Problem
Your daily sales table has rows only when something sold. A dashboard grouped by day therefore skips June 14th entirely — the chart draws a line through a day that never existed. The fix is always the same shape: generate the complete date spine first, then LEFT JOIN your data to it.
PostgreSQL: generate_series
The cleanest syntax anywhere. Full date spine joined to nulls = the report you actually want:
SELECT d::date AS day, COALESCE(SUM(s.amount), 0) AS revenue FROM generate_series('2026-01-01'::date, '2026-06-30'::date, '1 day') AS d LEFT JOIN sales s ON s.created_at::date = d::date GROUP BY d ORDER BY d;
To find ONLY the missing days, filter after the join: WHERE s.amount IS NULL.
MySQL 8: Recursive CTE Calendar
MySQL has no generate_series, so build the spine with a recursive CTE:
WITH RECURSIVE dates AS (SELECT DATE('2026-01-01') AS d UNION ALL SELECT DATE_ADD(d, INTERVAL 1 DAY) FROM dates WHERE d < DATE('2026-06-30')) SELECT dates.d, COALESCE(SUM(s.amount), 0) AS revenue FROM dates LEFT JOIN sales s ON DATE(s.created_at) = dates.d GROUP BY dates.d ORDER BY dates.d;
One MySQL gotcha: the default recursion limit (cte_max_recursion_depth = 1000) covers ~3 years of days, but a multi-year minute-level spine will hit it. Raise it per-session if needed: SET cte_max_recursion_depth = 100000;
BigQuery: UNNEST with GENERATE_DATE_ARRAY
SELECT day, COALESCE(SUM(s.amount), 0) AS revenue FROM UNNEST(GENERATE_DATE_ARRAY('2026-01-01', '2026-06-30')) AS day LEFT JOIN sales s ON DATE(s.created_at) = day GROUP BY day ORDER BY day;
Finding Gaps Instead of Filling Them
When you only want to know WHERE the holes are (data-pipeline monitoring, for example), a window-function approach avoids materializing a spine:
SELECT created_at::date AS gap_start, LEAD(created_at::date) OVER (ORDER BY created_at::date) AS gap_end FROM (SELECT DISTINCT created_at::date FROM sales) t WHERE LEAD(created_at::date) OVER (ORDER BY created_at::date) - created_at::date > 1;
Each row shows a gap boundary: last present day, next present day. Gaps of exactly 1 day difference mean no missing days between them — everything larger is a hole.
The Calendar Table Recommendation
If you write gap queries more than once a month, stop generating spines per query and create a permanent calendar table (day, week, month, quarter, is_holiday flags) with 50-100 years of rows. Queries become plain LEFT JOINs, every BI tool understands them, and you get business-day logic (holidays, fiscal weeks) for free. The generate_series one-liner is for ad-hoc; the calendar table is for production.
Common Mistakes
- Timezones: date_trunc comparisons across timezone boundaries create phantom missing days. Decide the reporting timezone and convert explicitly before grouping.
- Joining on the wrong side: spine must be the LEFT table. Reversed joins return the same gap you started with.
- Forgetting HAVING: when you only want missing days from a spine query,
WHERE data IS NULLfilters rows,HAVING SUM(...) IS NULLfilters groups — pick based on whether you grouped first.
Try It Yourself
Use our free SQL generator — type "daily revenue with missing days as zero for PostgreSQL" and it writes the generate_series version for your table.