The column month
seems to be redundant. Drop it from the table. start
has all the information you need.
(I'd rather not use start
as column name as that's a keyword in standard SQL - even if allowed in Postgres.)
SELECT date_trunc('month', start) AS mon, count(*) AS ct
FROM tbl
WHERE start >= '2021-01-01'
AND start < '2021-07-01'
GROUP BY 1
ORDER BY 1;
Use date_trunc()
to preserve chronological order. If you need month names in the result:
WITH cte(current_mon) AS (SELECT date_trunc('month', LOCALTIMESTAMP))
SELECT to_char(mon, 'Mon') AS month, COALESCE(data.ct, 0) AS ct
FROM cte c
CROSS JOIN generate_series(c.current_mon - interval '6 mon'
, c.current_mon - interval '1 mon'
, interval '1 mon') mon
LEFT JOIN (
SELECT date_trunc('month', start) AS mon, count(*)::int AS ct
FROM tbl, cte c
WHERE start >= c.current_mon - interval '6 mon'
AND start < c.current_mon
GROUP BY 1
) data USING (mon)
ORDER BY mon;
db<>fiddle here
Returns one row per month, in chronological order (considering the year, too, though it's not in your output!), and truly dynamic.
month |
ct |
Jan |
31 |
Feb |
28 |
Mar |
31 |
Apr |
0 |
May |
31 |
Jun |
30 |
Note how I first build timestamps for the last six months (excluding the current one) with generate_series()
in the first subquery mon
. See:
Then LEFT JOIN
to counts per month from the relevant time range. This way always returns the last 6 months, even if no rows are found at all. COALESCE
makes the count 0 instead of NULL for that case. Related:
Note, in particular, that it's faster to aggregate first and join later. See:
Using standard English month names and 3-letter-abbreviations.
Your original query produces that information in pivoted form: one month per column. But dynamic column names are not possible for a static SQL query. If you really need that, you need a 2-step flow of operation (two round trips to the server):
- Build the query.
- Execute it.
Well, you could prepare 12 different row types (that's the range of possible result types for your case) and use a polymorphic function to achieve it. But do you actually need the pivoted form?
OK, you asked for it ...
You want a simple call like this?
SELECT * FROM f_tbl_counts_6months(NULL::m6_jul);
It's possible. Here is a proof of concept.
But, honestly, I'd rather avoid the complication and just use the simple query above.
Create a polymorphic function:
CREATE OR REPLACE FUNCTION f_tbl_counts_6months(ANYELEMENT)
RETURNS SETOF ANYELEMENT
LANGUAGE plpgsql AS
$func$
DECLARE
_current_mon timestamp := date_trunc('month', LOCALTIMESTAMP);
BEGIN
-- to prevent incorrect column names, input row type must match current date:
IF right(pg_typeof($1)::text, 3) = to_char(_current_mon, 'mon') THEN
-- all good!
ELSE
RAISE EXCEPTION 'Current date is %. Function requires input >>%<<'
, CURRENT_DATE, 'NULL::m6_' || to_char(now(), 'mon');
END IF;
RETURN QUERY
SELECT a[2], a[2], a[3], a[4], a[5], a[6]
FROM (
SELECT ARRAY(
SELECT COALESCE(data.ct, 0)
FROM generate_series(_current_mon - interval '6 mon'
, _current_mon - interval '1 mon'
, interval '1 mon') mon
LEFT JOIN (
SELECT date_trunc('month', start) AS mon, count(*)::int AS ct
FROM tbl
GROUP BY 1
) data USING (mon)
ORDER BY mon
)
) sub(a);
END
$func$;
And 12 composite (row) types, one for each month of the year:
CREATE TYPE m6_jan AS ("Jul" int, "Aug" int, "Sep" int, "Oct" int, "Nov" int, "Dec" int);
CREATE TYPE m6_feb AS ("Aug" int, "Sep" int, "Oct" int, "Nov" int, "Dec" int, "Jan" int);
CREATE TYPE m6_mar AS ("Sep" int, "Oct" int, "Nov" int, "Dec" int, "Jan" int, "Feb" int);
CREATE TYPE m6_apr AS ("Oct" int, "Nov" int, "Dec" int, "Jan" int, "Feb" int, "Mar" int);
CREATE TYPE m6_may AS ("Nov" int, "Dec" int, "Jan" int, "Feb" int, "Mar" int, "Apr" int);
CREATE TYPE m6_jun AS ("Dec" int, "Jan" int, "Feb" int, "Mar" int, "Apr" int, "May" int);
CREATE TYPE m6_jul AS ("Jan" int, "Feb" int, "Mar" int, "Apr" int, "May" int, "Jun" int);
CREATE TYPE m6_aug AS ("Feb" int, "Mar" int, "Apr" int, "May" int, "Jun" int, "Jul" int);
CREATE TYPE m6_sep AS ("Mar" int, "Apr" int, "May" int, "Jun" int, "Jul" int, "Aug" int);
CREATE TYPE m6_oct AS ("Apr" int, "May" int, "Jun" int, "Jul" int, "Aug" int, "Sep" int);
CREATE TYPE m6_nov AS ("May" int, "Jun" int, "Jul" int, "Aug" int, "Sep" int, "Oct" int);
CREATE TYPE m6_dec AS ("Jun" int, "Jul" int, "Aug" int, "Sep" int, "Oct" int, "Nov" int);
Then the simple function call works and returns exactly your desired result:
SELECT * FROM f_tbl_counts_6months(NULL::m6_jul);
Jan |
Feb |
Mar |
Apr |
May |
Jun |
31 |
28 |
31 |
0 |
31 |
30 |
Why? How? See:
You are required to call with the right type. I built in a fail-safe to prevent wrong results. If you call with the wrong type, like the following call in July (currently):
SELECT * FROM f_tbl_counts_6months(NULL::m6_nov);
... the function throws an exception with instructions:
ERROR: Current date is 2021-07-15. Function requires input >>NULL::m6_jul<<
CONTEXT: PL/pgSQL function f_tbl_counts_6months(anyelement) line 9 at RAISE