When reporting growth over time, grouping by month alone is usually not enough. For charts and dashboards you typically want:
- Missing months to appear explicitly with a count of
0 - A cumulative total to show growth over time
Below are idiomatic solutions for MySQL, PostgreSQL, and Phoenix Ecto.
MySQL 8+
MySQL does not have a built-in series generator, so we use a recursive CTE.
WITH RECURSIVE months AS (
SELECT DATE_FORMAT(MIN(created_at), '%Y-%m-01') AS month
FROM your_table
UNION ALL
SELECT DATE_ADD(month, INTERVAL 1 MONTH)
FROM months
WHERE month < DATE_FORMAT(CURDATE(), '%Y-%m-01')
),
monthly_counts AS (
SELECT
DATE_FORMAT(created_at, '%Y-%m-01') AS month,
COUNT(*) AS monthly_count
FROM your_table
GROUP BY month
)
SELECT
m.month,
COALESCE(mc.monthly_count, 0) AS monthly_count,
SUM(COALESCE(mc.monthly_count, 0))
OVER (ORDER BY m.month) AS cumulative_count
FROM months m
LEFT JOIN monthly_counts mc USING (month)
ORDER BY m.month;
Notes
- Requires MySQL 8.0+
- Recursive CTE generates missing months
- Window function calculates the running total
PostgreSQL
PostgreSQL provides generate_series, which makes this much simpler.
WITH months AS (
SELECT generate_series(
date_trunc('month', MIN(created_at)),
date_trunc('month', CURRENT_DATE),
interval '1 month'
)::date AS month
FROM your_table
),
monthly_counts AS (
SELECT
date_trunc('month', created_at)::date AS month,
COUNT(*) AS monthly_count
FROM your_table
GROUP BY month
)
SELECT
m.month,
COALESCE(mc.monthly_count, 0) AS monthly_count,
SUM(COALESCE(mc.monthly_count, 0))
OVER (ORDER BY m.month) AS cumulative_count
FROM months m
LEFT JOIN monthly_counts mc USING (month)
ORDER BY m.month;
Notes
generate_seriesreplaces the recursive CTEdate_trunc('month', ...)is the idiomatic grouping method
Phoenix Ecto (PostgreSQL)
Ecto does not have a native abstraction for time series generation, but PostgreSQL’s strengths can still be used via fragment/1.
Example schema
Assume a schema with a created_at timestamp:
schema "items" do
timestamps()
end
Monthly counts with missing months and cumulative total
query =
from m in fragment("""
SELECT generate_series(
date_trunc('month', (SELECT MIN(created_at) FROM items)),
date_trunc('month', CURRENT_DATE),
interval '1 month'
)::date AS month
"""),
left_join: c in fragment("""
SELECT
date_trunc('month', created_at)::date AS month,
COUNT(*) AS monthly_count
FROM items
GROUP BY 1
"""),
on: fragment("? = ?", m.month, c.month),
select: %{
month: m.month,
monthly_count: coalesce(c.monthly_count, 0),
cumulative_count:
fragment(
"SUM(COALESCE(?, 0)) OVER (ORDER BY ?)",
c.monthly_count,
m.month
)
},
order_by: m.month
Repo.all(query)
Notes for Ecto
- Uses PostgreSQL-specific SQL via
fragment/1 - Keeps the time series generation in the database
- Returns a clean structure ready for charts or LiveView updates
Summary
Across MySQL, PostgreSQL, and Phoenix Ecto, the core idea is the same:
- Generate a complete month series
- Aggregate real data per month
- Left join and fill missing values with zero
- Use a window function for cumulative growth
Once you have this pattern in place, producing accurate growth charts becomes straightforward and reliable.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.