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