SQL GROUP BY, Aggregate Functions, and HAVING Explained

Learn GROUP BY grain, NULL-aware aggregates, WHERE versus HAVING, duplicate keys, conditional metrics, and distinct membership in PostgreSQL.

Author: PracHub

Published: 8/14/2026

SQL GROUP BY, Aggregate Functions, and HAVING Explained

By PracHub
August 14, 2026
20 min read
0
SQL GROUP BY, Aggregate Functions, and HAVING Explained

Quick Overview

A grain-first Data Analyst guide to PostgreSQL GROUP BY. Six verified walkthroughs cover grouped counts, NULL-aware numeric aggregates, WHERE versus HAVING, composite duplicate keys, conditional metrics, and distinct-date membership.

Data AnalystFree

GROUP BY changes the grain of a result. Detail rows become one row per distinct grouping key, and aggregates describe the rows that landed in each group. WHERE decides which rows may enter; HAVING decides which completed groups remain.

The safest way to build a grouped query is to write the intended output grain first. Then choose aggregates with explicit rules for NULLs, duplicate rows, and missing groups.

GROUP BY changes the grain

The source is one row per order. Grouping by customer and region produces one row per customer-region combination. Every selected column is either part of that key or an aggregate over the group's rows.

Input: orders

order_idcustomer_idregionorder_dateamountstatus
1101US2026-03-01250.00paid
2102US2026-03-01125.50paid
3101US2026-03-0180.00paid
4103CA2026-03-02300.00refunded
5104CA2026-03-03150.00paid
6101US2026-03-03NULLpending
7105EU2026-03-0340.00paid
8102US2026-03-0460.00refunded
9103CA2026-03-0420.00paid
10105EU2026-03-05400.00paid
SELECT
  customer_id,
  region,
  COUNT(*) AS order_count
FROM orders
GROUP BY customer_id, region
ORDER BY customer_id, region;
Row flow from order grain to customer-region grain Ten order rows are partitioned by customer and region, then collapse into five output groups with an order count. 10 order rowsone row per orderPartition by 2 keyscustomer + region5 group rowsone count per key
Adding a grouping column refines the key and can create more output groups; removing one combines groups.

Output

customer_idregionorder_count
101US3
102US2
103CA2
104CA1
105EU2

After grouping, a bare detail column such as order_id no longer names one value for the group. Either add a column to the grouping key, which changes the grain, or aggregate it with a rule that states what should survive.

Aggregates have explicit NULL behavior

COUNT(*) counts rows. COUNT(amount) and the numeric aggregates use only non-NULL amounts. The US group therefore has five orders but four known amounts; its average divides the known total by four, not five.

Input: orders

order_idcustomer_idregionorder_dateamountstatus
1101US2026-03-01250.00paid
2102US2026-03-01125.50paid
3101US2026-03-0180.00paid
4103CA2026-03-02300.00refunded
5104CA2026-03-03150.00paid
6101US2026-03-03NULLpending
7105EU2026-03-0340.00paid
8102US2026-03-0460.00refunded
9103CA2026-03-0420.00paid
10105EU2026-03-05400.00paid
SELECT
  region,
  COUNT(*) AS row_count,
  COUNT(amount) AS known_amounts,
  SUM(amount) AS total_amount,
  ROUND(AVG(amount), 2) AS avg_known_amount,
  MIN(amount) AS min_amount,
  MAX(amount) AS max_amount
FROM orders
GROUP BY region
ORDER BY region;
Row flow for NULL-aware aggregates by region Ten order rows form three region groups; row counts include every order while amount aggregates skip the one null amount. 10 order rows1 NULL amount3 region groupsrows vs known values3 summary rowsUS: 5 rows, 4 values
A missing amount is not automatically zero. Decide that policy before replacing NULL with a numeric value.

Output

regionrow_countknown_amountstotal_amountavg_known_amountmin_amountmax_amount
CA33470.00156.6720.00300.00
EU22440.00220.0040.00400.00
US54515.50128.8860.00250.00

An all-NULL group returns NULL from SUM, AVG, MIN, and MAX. Use COALESCE only when the reporting contract says that absence should display as zero. The SQL COUNT guide develops the row, non-NULL, and distinct counting choices.

WHERE filters rows; HAVING filters groups

This query first keeps paid order rows. It then forms customer groups and keeps groups whose paid revenue exceeds 300. The clauses are not interchangeable because they act on different grains.

Input: orders

order_idcustomer_idregionorder_dateamountstatus
1101US2026-03-01250.00paid
2102US2026-03-01125.50paid
3101US2026-03-0180.00paid
4103CA2026-03-02300.00refunded
5104CA2026-03-03150.00paid
6101US2026-03-03NULLpending
7105EU2026-03-0340.00paid
8102US2026-03-0460.00refunded
9103CA2026-03-0420.00paid
10105EU2026-03-05400.00paid
SELECT
  customer_id,
  COUNT(*) AS paid_orders,
  SUM(amount) AS paid_revenue
FROM orders
WHERE status = 'paid'
GROUP BY customer_id
HAVING SUM(amount) > 300
ORDER BY customer_id;
Row flow through WHERE, GROUP BY, and HAVING Ten orders are filtered to seven paid rows, grouped by customer, and filtered to the two customer groups with more than 300 in paid revenue. 10 ordersWHERE keeps 7 paid5 customer groupsSUM paid amountHAVING keeps 2101 and 105
The row filter changes what enters each sum; the group filter tests the completed sums.

Output

customer_idpaid_orderspaid_revenue
1012330.00
1052440.00

WHERE cannot reference SUM(amount) because the aggregate does not exist yet. HAVING can. The full clause sequence is explained in SQL order of operations.

Multiple keys define combinations and duplicates

To find repeated customer-day combinations, group by the columns that define the candidate duplicate and keep groups with more than one row. Do not include the unique order ID, or every group would have size one.

Input: orders

order_idcustomer_idregionorder_dateamountstatus
1101US2026-03-01250.00paid
2102US2026-03-01125.50paid
3101US2026-03-0180.00paid
4103CA2026-03-02300.00refunded
5104CA2026-03-03150.00paid
6101US2026-03-03NULLpending
7105EU2026-03-0340.00paid
8102US2026-03-0460.00refunded
9103CA2026-03-0420.00paid
10105EU2026-03-05400.00paid
SELECT
  customer_id,
  order_date,
  COUNT(*) AS orders_on_day
FROM orders
GROUP BY customer_id, order_date
HAVING COUNT(*) > 1
ORDER BY customer_id, order_date;
Row flow for duplicate customer-day groups Ten orders form customer-date combinations, counts are computed for each combination, and one combination with two rows survives having. 10 order rowscustomer × date9 combinationscount each bucket1 repeated key101 on March 1
SQL identifies repeated keys; whether two purchases are truly duplicate business events needs a separate definition.

Output

customer_idorder_dateorders_on_day
1012026-03-012

GROUP BY emits only combinations present in the data. A complete region-by-date grid, including zero rows, needs a scaffold of desired keys followed by a left join.

Conditional and distinct aggregates

Conditional aggregates summarize one group several ways. Here every region reports its total rows, paid rows, paid revenue, and paid share. Each numerator and denominator is visible in the same output.

Input: orders

order_idcustomer_idregionorder_dateamountstatus
1101US2026-03-01250.00paid
2102US2026-03-01125.50paid
3101US2026-03-0180.00paid
4103CA2026-03-02300.00refunded
5104CA2026-03-03150.00paid
6101US2026-03-03NULLpending
7105EU2026-03-0340.00paid
8102US2026-03-0460.00refunded
9103CA2026-03-0420.00paid
10105EU2026-03-05400.00paid
SELECT
  region,
  COUNT(*) AS total_orders,
  COUNT(*) FILTER (WHERE status = 'paid') AS paid_orders,
  COALESCE(SUM(amount) FILTER (WHERE status = 'paid'), 0) AS paid_revenue,
  ROUND(
    COUNT(*) FILTER (WHERE status = 'paid')::numeric / COUNT(*),
    2
  ) AS paid_rate
FROM orders
GROUP BY region
ORDER BY region;
Row flow for conditional regional aggregates Ten orders form three regional groups; filtered aggregates count and sum paid rows, then paid count divides by total count. 10 orders3 regionsFilter inside aggregatespaid count and amount3 metric rowscount, sum, rate
All measures share the same regional groups, which makes the paid-rate denominator auditable.

Output

regiontotal_orderspaid_orderspaid_revenuepaid_rate
CA32170.000.67
EU22440.001.00
US53455.500.60

The CASE equivalent is covered in SQL CASE WHEN. With COUNT(CASE ...), omit ELSE 0 because zero is non-NULL and would count.

Distinct aggregation is useful when membership requires separate values rather than a row count. Customer 101 has two orders on March 1; customer 102 has one order on March 1 and one on March 4. Only customer 102 appears on both requested dates.

Input: orders

order_idcustomer_idregionorder_dateamountstatus
1101US2026-03-01250.00paid
2102US2026-03-01125.50paid
3101US2026-03-0180.00paid
4103CA2026-03-02300.00refunded
5104CA2026-03-03150.00paid
6101US2026-03-03NULLpending
7105EU2026-03-0340.00paid
8102US2026-03-0460.00refunded
9103CA2026-03-0420.00paid
10105EU2026-03-05400.00paid
SELECT customer_id
FROM orders
WHERE order_date IN (DATE '2026-03-01', DATE '2026-03-04')
GROUP BY customer_id
HAVING COUNT(DISTINCT order_date) = 2
ORDER BY customer_id;
Row flow for finding customers active on both dates Ten orders filter to five rows on two requested dates, group by customer, and distinct date counts leave only customer 102 with both dates. 10 ordersfilter to 2 datesGroup by customercount distinct dates1 qualifying rowcustomer 102
A plain row count would also admit customer 101, whose two rows both belong to the same date.

Output

customer_id
102

For more exercises built around grouping grain and edge fixtures, use SQL practice questions.

FAQ

What columns may appear in SELECT after GROUP BY?

Grouping keys and aggregate expressions are the portable choices. Some engines recognize functional dependencies in specific cases, but listing the intended keys keeps the output grain clear.

Does GROUP BY remove NULL keys?

No. Rows with NULL in a grouping column form a NULL group. Aggregates inside that group still follow their own NULL rules.

When should I use DISTINCT instead of GROUP BY?

Use DISTINCT when you only need unique projected rows. Use GROUP BY when each key needs an aggregate or HAVING condition.

Can HAVING appear without GROUP BY?

PostgreSQL permits it. The input is treated as one group, and HAVING either keeps or removes that aggregate result. Use the form only when a one-group output matches the question.

Why can a grouped query return no rows?

HAVING can remove every group. Downstream code expecting a scalar should distinguish an empty grouped result from an ungrouped aggregate that returns one row containing zero or NULL.


Comments (0)