Bucketing time series and filling gaps with generate_series

Generate the periods with func.generate_series(...).table_valued(...) and left-join the aggregate to them, so buckets with no rows appear with coalesce(value, 0) — a grouped aggregate alone omits empty periods, which breaks charts and makes every lag() reach the wrong neighbour. This guide belongs to window functions and analytical queries.

Quick Answer

GROUP BY returns groups that exist. A day with no orders produces no row, and everything downstream assumes otherwise.

Missing rows are missing periods Left: grouping by day returns only the days that had orders, so a chart skips quiet days entirely and every window function over the result reaches the wrong neighbour. Right: generate_series produces every day in the range and a left join attaches the aggregate, so quiet days appear with zero and the sequence is dense. GROUP BY day only days with no orders are absent the chart joins Monday to Thursday lag() reaches the wrong day averages divide by the wrong count generate_series + LEFT JOIN every day in the range coalesce(revenue, 0) dense, chart-ready window functions are correct A dense series is a precondition for lag, moving averages and anything that counts periods.

Before — only the days that had data:

import datetime as dt

from sqlalchemy import func, select

from shop.models import Order

stmt = (
    select(
        func.date_trunc("day", Order.placed_at).label("day"),
        func.sum(Order.total_cents).label("revenue_cents"),
    )
    .where(Order.placed_at >= start, Order.placed_at < end)
    .group_by(func.date_trunc("day", Order.placed_at))
    .order_by(func.date_trunc("day", Order.placed_at))
)
# A quiet Sunday is simply absent, so the chart draws a straight line over it
# and lag() compares Monday with Saturday.

After — every day in the range, zero where nothing happened:

import datetime as dt

from sqlalchemy import Date, cast, func, select, text

from shop.models import Order

ZONE = "Europe/Berlin"


def daily_revenue(start: dt.date, end: dt.date):
    day = func.generate_series(
        cast(start, Date),
        cast(end, Date),
        text("interval '1 day'"),
    ).table_valued("day")

    bucket = func.date_trunc("day", func.timezone(ZONE, Order.placed_at))

    daily = (
        select(bucket.label("day"), func.sum(Order.total_cents).label("revenue_cents"))
        .where(Order.placed_at >= start, Order.placed_at < end,
               Order.status != "cancelled")
        .group_by(bucket)
        .cte("daily")
    )

    return (
        select(
            day.c.day,
            func.coalesce(daily.c.revenue_cents, 0).label("revenue_cents"),
        )
        .select_from(day)
        .outerjoin(daily, daily.c.day == day.c.day)
        .order_by(day.c.day)
    )

Three things make it correct: the calendar is the left side of the join, the range is half-open (>= start, < end), and the bucket is truncated in a named time zone rather than whatever the connection happens to be set to.

Execution Context & Async Workflow Integration

generate_series is a set-returning function: given a start, an end and a step, it produces one row per step. SQLAlchemy exposes set-returning functions through table_valued(), which places the call in the FROM clause and names its output column so it can be selected, joined and ordered like any table.

Calendar first, aggregate second Four steps. generate_series produces one row per day between the bounds, as a table-valued function in the FROM clause. The aggregate is computed separately, grouped by the truncated day. A left join from the calendar to the aggregate attaches values where they exist. coalesce turns the missing ones into zero, and the result has one row per day whether or not anything happened. generate_series(start, end, interval) one row per day in the FROM clause the aggregate, grouped by day only days with data computed separately LEFT JOIN calendar → aggregate on the truncated day nothing is dropped coalesce(value, 0) zero rather than NULL The join direction matters: the calendar must be the left side, or empty days disappear again.

The join is where the density comes from, and its direction is the part that is easy to get wrong. The calendar must be the left side and the aggregate the right, with outerjoin. Reversed, or with an inner join, the empty days are filtered out again and the query is no better than the grouped aggregate alone.

coalesce then decides what an empty bucket means, and the choice is not cosmetic. For a count or a sum, zero is correct: nothing was sold. For an average, a temperature or a gauge, zero is a lie and NULL is the truth — no reading was taken. Reports that fill measurements with zero produce charts that dip to the axis on every gap, which readers interpret as a real drop.

Time zones decide bucket boundaries, and date_trunc on a timestamptz uses the session time zone. That means the same query returns different daily totals depending on the connection's setting, and rows near midnight move between days. Naming the zone in the query — date_trunc('day', timezone('Europe/Berlin', placed_at)) — makes the boundaries explicit and reproducible. Pinning the engine to UTC, as configuring connect_args and server_settings for asyncpg suggests, removes the variation but does not make a UTC day the business day.

Half-open ranges avoid the other double-counting trap. placed_at >= start AND placed_at < end counts each row once when consecutive reports are run; BETWEEN includes both endpoints, so the row at exactly midnight appears in two months. Half-open bounds also let a plain index on placed_at serve the filter, which date_trunc(placed_at) = ... would not — the indexability point from writing hybrid properties that work in Python and SQL.

Under async the whole query is built synchronously and awaited once, returning rows. Nothing lazy-loads, which is why reporting code is some of the least troublesome async SQLAlchemy to write.

Resolving Warnings, Errors & Common Mistakes

Exact error or symptomRoot CauseProduction Fix
Empty periods are still missingThe join is inner, or the calendar is on the right.select_from(calendar).outerjoin(aggregate, ...).
ProgrammingError: function generate_series(date, date, unknown) is not uniqueThe step argument has no type, so PostgreSQL cannot resolve the overload.text("interval '1 day'"), or cast the arguments.
Charts dip to zero on days with no readingscoalesce(avg, 0) applied to a measurement.Leave measurements NULL; fill only counts and sums.
Daily totals differ between two runs of the same reportdate_trunc used the session time zone.Convert to a named zone inside the query.
A row appears in two monthsBETWEEN includes both endpoints.Half-open bounds: >= start AND < end.
The query does a sequential scanThe filter was written on date_trunc(column).Filter the raw column; bucket in the select list.
A gap-filled year of minutes returns 525,600 rowsThe bucket width does not match the range.Match the granularity to the range, and aggregate coarser for long ranges.
Four decisions in a bucketed report Four tiles. The bucket width — hour, day, week, month — sets the granularity. The time zone decides where a day begins, which changes which bucket a row near midnight falls into. The bounds decide whether the range is closed or half-open, and a half-open range avoids double-counting the boundary. And the fill value decides what an empty bucket means: zero for counts, NULL for measurements. bucket width date_trunc('day', ...) hour, day, week, month time zone timezone('Europe/Berlin', ts) where the day begins half-open bounds >= start AND < end no double counting fill value coalesce(count, 0) or NULL for measurements Zero and NULL are different answers: nothing sold, versus no reading taken.

The indexability mistake is worth showing, because the two versions look equivalent:

# Not indexable: the expression is applied to every row before comparison.
bad = select(...).where(func.date_trunc("day", Order.placed_at) >= start)

# Indexable: a plain range predicate on the column.
good = select(...).where(Order.placed_at >= start, Order.placed_at < end)

Bucketing belongs in the GROUP BY and the select list; filtering belongs on the raw column. An index on placed_at then serves the range, and the truncation is applied only to the rows that survive it.

For very long ranges at fine granularity, the fix is not a faster query but a different one. A year of five-minute buckets is over a hundred thousand rows, which no chart can display; pre-aggregating into a summary table or a materialised view, and reading from that, is both faster and more honest about what the reader can see. Maintaining such a table in the same transaction as the writes that change it is the data-modifying CTE pattern.

Advanced: Dense Series for Window Functions and Per-Group Gaps

The strongest reason to fill gaps is not presentation — it is that window functions count rows, not periods. lag over a sparse series reaches the previous row, so a missing month makes the comparison silently wrong. Filling first, navigating second, is the order that works:

Whose day is it? Left: date_trunc on a timestamptz truncates in the session time zone, so the same query buckets differently depending on which connection ran it, and a row at 23:30 local can land in either day. Right: converting to a named zone before truncating makes the bucket boundaries explicit and identical on every connection. date_trunc('day', placed_at) uses the session TimeZone differs per connection 23:30 rows move between days totals change with the setting date_trunc('day', timezone(ZONE, ts)) the zone is in the query identical everywhere boundaries are explicit and testable Pinning the engine to UTC is not enough: a business day is rarely a UTC day.
import datetime as dt

from sqlalchemy import Date, cast, func, select, text

from shop.models import Order

ZONE = "Europe/Berlin"


def monthly_growth(start: dt.date, end: dt.date):
    month = func.generate_series(
        cast(start, Date),
        cast(end, Date),
        text("interval '1 month'"),
    ).table_valued("month")

    bucket = func.date_trunc("month", func.timezone(ZONE, Order.placed_at))
    monthly = (
        select(bucket.label("month"), func.sum(Order.total_cents).label("revenue_cents"))
        .where(Order.placed_at >= start, Order.placed_at < end)
        .group_by(bucket)
        .cte("monthly")
    )

    dense = (
        select(
            month.c.month,
            func.coalesce(monthly.c.revenue_cents, 0).label("revenue_cents"),
        )
        .select_from(month)
        .outerjoin(monthly, monthly.c.month == month.c.month)
        .cte("dense")
    )

    previous = func.lag(dense.c.revenue_cents, 1).over(order_by=dense.c.month)
    return select(
        dense.c.month,
        dense.c.revenue_cents,
        (dense.c.revenue_cents - previous).label("change_cents"),
    ).order_by(dense.c.month)

Because dense has a row for every month, lag now genuinely means "last month" — the correctness point raised in computing period-over-period change with lag and lead.

When the report is per group — revenue per product per day, with every product appearing on every day — the calendar has to be crossed with the groups:

from sqlalchemy import Date, cast, func, select, text

from shop.models import Order, Product

day = func.generate_series(
    cast(start, Date),
    cast(end, Date),
    text("interval '1 day'"),
).table_valued("day")

products = select(Product.id.label("product_id")).where(Product.active.is_(True)).cte("products")

grid = select(day.c.day, products.c.product_id).select_from(day).join(products, text("true")).cte("grid")

stmt = (
    select(
        grid.c.day,
        grid.c.product_id,
        func.coalesce(daily.c.revenue_cents, 0).label("revenue_cents"),
    )
    .select_from(grid)
    .outerjoin(
        daily,
        (daily.c.day == grid.c.day) & (daily.c.product_id == grid.c.product_id),
    )
    .order_by(grid.c.product_id, grid.c.day)
)

The cross join is deliberate and its size is the thing to watch: days multiplied by products. Thirty days and five hundred products is fifteen thousand rows, which is fine; a year and fifty thousand products is not, and needs the product set narrowed — to those with any activity in the range — before crossing.

Calendar Tables and Business Calendars

generate_series produces a sequence of instants. A business report usually needs more than that: whether a day was a trading day, which fiscal quarter it belonged to, how weeks are numbered for this organisation. That information cannot be generated, so it lives in a table.

Three ways to get a calendar Three options. generate_series is a PostgreSQL function that produces the rows in the query itself, with nothing to maintain. A calendar table is portable and can carry business attributes such as holidays and fiscal periods. And generating the range in Python and passing it as a VALUES list works anywhere but sends one parameter per period. generate_series(...) in the query nothing to maintain; PostgreSQL only a calendar table portable, and can carry holidays, fiscal periods and week numbering a VALUES list built in Python portable, but one bound parameter per period — fine for months, not for minutes
import datetime as dt

from sqlalchemy import Boolean, Date, Integer, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
    pass


class CalendarDay(Base):
    __tablename__ = "calendar_days"

    day: Mapped[dt.date] = mapped_column(Date, primary_key=True)
    is_business_day: Mapped[bool] = mapped_column(Boolean, default=True)
    holiday_name: Mapped[str | None] = mapped_column(String(64))
    fiscal_year: Mapped[int] = mapped_column(Integer)
    fiscal_quarter: Mapped[int] = mapped_column(Integer)
    iso_week: Mapped[int] = mapped_column(Integer)

A calendar table gives three things generate_series cannot. Reports can join to it and filter on business days, so "average daily revenue" divides by the right number. Fiscal periods stop being reimplemented in every query. And because it is an ordinary table, it works on any database, which matters for a codebase that is not PostgreSQL-only.

Populating it is a one-off job per year, and the source for holidays is a business decision rather than a technical one — a library, a finance team's spreadsheet, or a vendor feed. The rows themselves can be generated with generate_series and then annotated:

from sqlalchemy import func, select, text
from sqlalchemy.dialects.postgresql import insert

from shop.models import CalendarDay


async def populate_year(session, year: int, holidays: dict[dt.date, str]) -> None:
    days = [
        {
            "day": day,
            "is_business_day": day.weekday() < 5 and day not in holidays,
            "holiday_name": holidays.get(day),
            "fiscal_year": year if day.month >= 4 else year - 1,
            "fiscal_quarter": ((day.month - 4) % 12) // 3 + 1,
            "iso_week": day.isocalendar().week,
        }
        for day in _dates_in(year)
    ]
    stmt = insert(CalendarDay).values(days).on_conflict_do_nothing(
        index_elements=[CalendarDay.day]
    )
    await session.execute(stmt)
    await session.commit()

Which to use comes down to what the report is for. An operational chart of the last thirty days is well served by generate_series in the query: nothing to maintain, nothing to keep in sync. A financial report that must agree with the finance team's definition of a quarter needs the table, because the definition is data. Many systems have both, and the rule that keeps them from disagreeing is that the table is authoritative wherever it exists — a report should not generate its own calendar for a period the table already describes.

Frequently Asked Questions

How do I include periods with no rows in a grouped report?

Generate the periods with generate_series, put them on the left of an outer join to the aggregate, and coalesce the value. A GROUP BY alone returns only groups that exist.

Should an empty bucket be zero or NULL?

Zero for counts and sums — nothing happened. NULL for averages and measurements — no reading was taken. Filling a measurement with zero makes charts dip to the axis on every gap.

Why do my daily totals change between environments?

Because date_trunc on a timestamptz uses the session time zone. Convert to a named zone inside the query so the bucket boundaries are part of the statement.

Is generate_series or a calendar table better?

generate_series for operational charts: nothing to maintain. A calendar table when reports need business attributes — holidays, fiscal periods, week numbering — because those are data, not arithmetic.