Do not place expressions/functions in GROUP BY. Compute all derived grouping keys (casts, DATE_TRUNC, COALESCE, CASE, math) upstream in named columns (CTEs), then GROUP BY those columns.
Why
- Single source of truth for grouping logic (no duplicated formulas).
- Easier to read, test, and change (you can inspect the pre-aggregation CTE).
- Avoids subtle mismatches where the
SELECTandGROUP BYuse “almost the same” expression. - Query planners can optimize better when grouping columns are explicit.

Anti-patterns (transformations inside GROUP BY)
-- Casting inside GROUP BYSELECT CAST(order_ts AS DATE) AS order_date, SUM(revenue) AS total_revFROM bronze.ordersGROUP BY CAST(order_ts AS DATE);
-- Time bucketing in GROUP BY
SELECT DATE_TRUNC('month', event_ts) AS month_start, COUNT(*) AS events
FROM raw.events
GROUP BY DATE_TRUNC('month', event_ts);
-- Normalization in GROUP BY
SELECT UPPER(TRIM(country_code)) AS country, COUNT(*) AS users
FROM dim.users
GROUP BY UPPER(TRIM(country_code));
-- Arithmetic in GROUP BY
SELECT (quantity * unit_price) AS gross, COUNT(*) AS n
FROM fact.sales
GROUP BY (quantity * unit_price);
Best practice: derive first, then group
WITH orders_prepared AS (SELECTorder_id,customer_id,CAST(order_ts AS DATE) AS order_date, -- derive onceCAST(revenue AS NUMERIC) AS revenueFROM bronze.ordersWHERE order_ts IS NOT NULL)SELECTorder_date,SUM(revenue) AS total_revFROM orders_preparedGROUP BY order_date; -- group by named column, no expressions
Time bucketing (month) done upstream
WITH events_prepared AS (SELECTevent_id,CAST(event_ts AS TIMESTAMP) AS event_ts,DATE_TRUNC('month', CAST(event_ts AS TIMESTAMP)) AS month_start -- derive bucketFROM raw.eventsWHERE event_ts >= CURRENT_DATE - INTERVAL '365 days')SELECTmonth_start,COUNT(*) AS eventsFROM events_preparedGROUP BY month_start;
Normalization before grouping
WITH users_prepared AS (SELECTuser_id,CASEWHEN country_code IS NULL OR TRIM(country_code) = '' THEN 'UNKNOWN'ELSE UPPER(TRIM(country_code))END AS country_normFROM dim.users)SELECTcountry_norm,COUNT(*) AS usersFROM users_preparedGROUP BY country_norm;
Arithmetic / banding before grouping
WITH sales_prepared AS (SELECTsale_id,(quantity * unit_price) - COALESCE(discount_amount, 0) AS net_amount,CASEWHEN (quantity * unit_price) - COALESCE(discount_amount, 0) >= 1000 THEN 'HIGH'WHEN (quantity * unit_price) - COALESCE(discount_amount, 0) >= 200 THEN 'MEDIUM'ELSE 'LOW'END AS revenue_bandFROM fact.sales)SELECTrevenue_band,COUNT(*) AS n_orders,SUM(net_amount) AS total_netFROM sales_preparedGROUP BY revenue_band;
Works with other rules (pure UNION ALL + no transforms in GROUP BY)
WITH a_prepared AS (SELECTorder_id,customer_id,CAST(order_ts AS DATE) AS order_date,CAST(revenue AS NUMERIC) AS revenue,'A' AS _srcFROM src.orders_aWHERE order_ts >= DATE '2025-01-01'),
b_prepared AS (SELECTorder_id,customer_id,CAST(order_ts AS DATE) AS order_date,CAST(revenue AS NUMERIC) AS revenue,'B' AS _srcFROM src.orders_bWHERE order_ts >= DATE '2025-01-01'),combined_orders AS ( -- pure union: only aligned columns, no expressionsSELECT order_id, customer_id, order_date, revenue, _src FROM a_preparedUNION ALLSELECT order_id, customer_id, order_date, revenue, _src FROM b_prepared),agg AS ( -- derive any additional grouping keys here if neededSELECTcustomer_id,order_date,revenueFROM combined_orders)SELECTcustomer_id,order_date,SUM(revenue) AS total_revFROM aggGROUP BY customer_id, order_date; -- no expressions in GROUP BY
Common patterns moved before the GROUP BY
- Type casting:
CAST(ts AS DATE) AS order_date - Time bucketing:
DATE_TRUNC('month', ts) AS month_start - Text normalization:
UPPER(TRIM(code)) AS code_norm - Null handling:
COALESCE(dim, 'UNKNOWN') AS dim_filled - Math/ratios:
(a/b) AS ratio,(qty*price) AS gross - Categorization:
CASE ... END AS bucket
Quick checklist
- Create a CTE that defines every grouping key as a named column.
- The final aggregation query should:
SELECTnamed grouping columns and aggregates,GROUP BYthe same named columns,- contain no functions/expressions in the
GROUP BY.
- Combine with the other rules:
- Shift logic left → derive grouping keys early.
- Exclude inline
UNION ALL→ use a named union CTE. - Keep
UNION ALLpure → union only prepped columns; no transforms there.