575 words, 3 min read

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_series replaces the recursive CTE
  • date_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:

  1. Generate a complete month series
  2. Aggregate real data per month
  3. Left join and fill missing values with zero
  4. Use a window function for cumulative growth

Once you have this pattern in place, producing accurate growth charts becomes straightforward and reliable.