Skip to main content
SQL intermediate Lesson 9 of 22

Aggregations and GROUP BY

Summarize data with COUNT, SUM, AVG, GROUP BY, HAVING, and the FILTER clause.

Aggregation functions collapse multiple rows into a single summary value. They’re how you answer analytical questions — “how much revenue did we make last month?”, “which departments have the highest average salary?”, “how many customers placed more than five orders?” Without aggregations, you’d need to load all the raw rows into your application and compute these values there, which doesn’t scale.

The Core Aggregate Functions

The five standard aggregates — COUNT, SUM, AVG, MIN, MAX — cover the vast majority of summarization needs. They all ignore NULL values in their input (except COUNT(*), which counts rows regardless of NULLs).

SELECT
  COUNT(*)            AS total_rows,       -- counts all rows including NULLs
  COUNT(email)        AS rows_with_email,  -- excludes rows where email IS NULL
  SUM(total)          AS revenue,
  AVG(total)          AS avg_order_value,
  MIN(created_at)     AS first_order,
  MAX(created_at)     AS latest_order
FROM orders;

COUNT(*) counts every row. COUNT(column) skips NULLs — useful for counting how many rows actually have a value in an optional column.

GROUP BY

GROUP BY is what turns aggregate functions from “one number for the whole table” into “one number per group.” It splits rows into groups based on one or more columns, then applies the aggregate function independently to each group. This is how you answer “per customer”, “per department”, or “per month” questions.

-- Orders and revenue per customer — one row per customer_id
SELECT
  customer_id,
  COUNT(*)        AS order_count,
  SUM(total)      AS total_spent,
  AVG(total)      AS avg_order
FROM orders
GROUP BY customer_id
ORDER BY total_spent DESC;

Every column in SELECT that is not an aggregate function must appear in GROUP BY. PostgreSQL will error if you forget one.

Grouping by multiple columns

When you group by multiple columns, each unique combination of those columns forms its own group. This lets you break down metrics along two dimensions simultaneously.

-- Monthly revenue broken down by status — each month/status pair is a separate group
SELECT
  DATE_TRUNC('month', created_at) AS month,
  status,
  COUNT(*)                        AS count,
  SUM(total)                      AS revenue
FROM orders
GROUP BY DATE_TRUNC('month', created_at), status
ORDER BY month, status;

HAVING: Filtering Groups

WHERE runs before grouping and filters individual rows. HAVING runs after grouping and filters the resulting groups. The key distinction: you can use aggregate functions in HAVING but not in WHERE. Use HAVING whenever your filter condition is based on a computed group statistic.

-- Only customers who placed more than 5 orders
SELECT customer_id, COUNT(*) AS order_count
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > 5;

-- Departments where average salary exceeds 80000
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
HAVING AVG(salary) > 80000;

You can combine WHERE and HAVING in the same query — WHERE narrows the rows fed into the aggregation, HAVING narrows the groups in the output:

-- Active departments with at least 10 employees
SELECT department_id, COUNT(*) AS headcount
FROM employees
WHERE status = 'active'          -- filter rows first (before grouping)
GROUP BY department_id
HAVING COUNT(*) >= 10            -- then filter groups (after grouping)
ORDER BY headcount DESC;

COUNT(DISTINCT column)

COUNT(DISTINCT column) counts unique values rather than total rows. This is essential for metrics like “unique active users” or “distinct customers who placed orders” — where counting raw rows would overcount users who appear multiple times.

-- Distinct customers who placed orders each month
SELECT
  DATE_TRUNC('month', created_at) AS month,
  COUNT(DISTINCT customer_id)     AS unique_customers
FROM orders
GROUP BY DATE_TRUNC('month', created_at);

The FILTER Clause

FILTER is a clean way to compute multiple conditional aggregates in a single pass over the data. Without it, you’d need multiple subqueries or verbose CASE WHEN expressions. With FILTER, the intent is clear and the query touches the table only once.

-- Order counts and revenue broken down by status — all in one query
SELECT
  COUNT(*)                                     AS total,
  COUNT(*) FILTER (WHERE status = 'pending')   AS pending,
  COUNT(*) FILTER (WHERE status = 'shipped')   AS shipped,
  COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
  SUM(total) FILTER (WHERE status = 'shipped') AS shipped_revenue
FROM orders;

The equivalent with CASE WHEN is more verbose and harder to scan:

-- Same result, harder to read — FILTER is preferred
SELECT
  COUNT(*),
  SUM(CASE WHEN status = 'pending'   THEN 1 ELSE 0 END) AS pending,
  SUM(CASE WHEN status = 'shipped'   THEN 1 ELSE 0 END) AS shipped,
  SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM orders;

Aggregates with JOINs

Aggregating across joined tables is a common pattern — you want the customer’s name alongside their order totals, which requires joining before aggregating. The key detail: use LEFT JOIN if you want customers with zero orders to appear in the result.

-- Revenue and order count per customer, including customers with no orders
SELECT
  c.name,
  COUNT(o.id)   AS orders,   -- counts NULL as 0 for customers with no orders
  SUM(o.total)  AS revenue
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name
ORDER BY revenue DESC NULLS LAST;

GROUPING SETS and ROLLUP

ROLLUP generates subtotals and grand totals alongside the detail rows in a single query. This is more efficient than running separate queries for each level of aggregation and then combining them in application code.

-- Revenue by year, then by year+month, then a grand total — all in one result
SELECT
  EXTRACT(year  FROM created_at) AS yr,
  EXTRACT(month FROM created_at) AS mo,
  SUM(total)                     AS revenue
FROM orders
GROUP BY ROLLUP (
  EXTRACT(year FROM created_at),
  EXTRACT(month FROM created_at)
)
ORDER BY yr NULLS LAST, mo NULLS LAST;
-- NULL in yr or mo indicates a subtotal/grand total row

NULL in the yr or mo column indicates a subtotal or grand total row.

Frequently Asked Questions

What is the difference between WHERE and HAVING?
WHERE filters individual rows before grouping. HAVING filters groups after GROUP BY is applied. You can use aggregate functions in HAVING but not in WHERE.
Does COUNT(*) count NULL values?
COUNT(*) counts all rows including those with NULLs. COUNT(column) counts only rows where that column is not NULL.