In Postgres, there is a dedicated function for this (several overloaded variants, really): width_bucket()
.
One additional difficulty: it does not work on type timestamp
directly. But you can work with extracted epoch values like this:
WITH cte(min_ts, max_ts, buckets) AS ( -- interval and nr of buckets here
SELECT timestamp '2019-01-01T00:00:00'
, timestamp '2019-01-02T00:00:00'
, 2
)
SELECT width_bucket(extract(epoch FROM t.created_at)
, extract(epoch FROM c.min_ts)
, extract(epoch FROM c.max_ts)
, c.buckets) AS bucket
, count(*) AS ct
FROM tbl t
JOIN cte c ON t.created_at >= min_ts -- incl. lower
AND t.created_at < max_ts -- excl. upper
GROUP BY 1
ORDER BY 1;
Empty buckets (intervals with no rows in it) are not returned at all. Your
comment seems to suggest you want that.
Notably, this accesses the table once - as requested and as opposed to generating intervals first and then joining to the table (repeatedly).
See:
That does not yet include effective bounds, just bucket numbers. Actual bounds can be added cheaply:
WITH cte(min_ts, max_ts, buckets) AS ( -- interval and nr of buckets here
SELECT timestamp '2019-01-01T00:00:00'
, timestamp '2019-01-02T00:00:00'
, 2
)
SELECT b.*
, min_ts + ((c.max_ts - c.min_ts) / c.buckets) * (bucket-1) AS lower_bound
FROM (
SELECT width_bucket(extract(epoch FROM t.created_at)
, extract(epoch FROM c.min_ts)
, extract(epoch FROM c.max_ts)
, c.buckets) AS bucket
, count(*) AS ct
FROM tbl t
JOIN cte c ON t.created_at >= min_ts -- incl. lower
AND t.created_at < max_ts -- excl. upper
GROUP BY 1
ORDER BY 1
) b, cte c;
Now you only change input values in the CTE to adjust results.
db<>fiddle here