SQL Aggregation — Summarizing Data with COUNT, SUM, AVG, GROUP BY, and Window Functions
Status: Active | Last Updated: 2026-08-29 Category: SQL — Aggregation Prerequisites: SQL fundamentals, SQL Joins Tags: sql, aggregation, count, sum, avg, min, max, group-by, having, subquery, window-functions, row-number, rank Estimated Time: 4-5 hours (Self-paced, includes lab time)
Summary
Aggregation functions turn many rows into a single summary value:
COUNT(*),SUM(amount),AVG(price),MIN(),MAX(). Combined withGROUP BY, they answer business questions like "how many orders per customer?" and "what is the average revenue by region?" Window functions extend this further, computing per-row aggregates without collapsing rows — enabling rankings, running totals, and trend analysis.
What You'll Learn (Core Competencies)
- Use the five core aggregate functions:
COUNT,SUM,AVG,MIN,MAX - Write
GROUP BYqueries that summarize data by category - Distinguish
HAVING(post-aggregation filter) fromWHERE(pre-aggregation filter) - Compose subqueries for multi-step aggregation problems
- Apply window functions (
ROW_NUMBER,RANK,LAG,LEAD) for per-row contextual calculations
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
What Aggregation Does
Raw SQL returns rows. Aggregation collapses rows into summary values. The distinction:
Raw rows (transactional view):
order_id | user_id | amount
---------|---------|-------
1 | 101 | 50.00
2 | 101 | 75.00
3 | 102 | 30.00
Aggregated (analytical view — one row per user):
user_id | order_count | total_spent
--------|-------------|------------
101 | 2 | 125.00
102 | 1 | 30.00
Aggregation is the bridge between transactional data (day-to-day operations) and analytical data (business decisions).
Aggregation and NULL
COUNT(*)counts all rows including those with NULLsCOUNT(col)counts only non-NULL values of that columnSUM(col),AVG(col),MIN(col),MAX(col)ignore NULLs- A column of all NULLs returns NULL from
SUM/AVG/MIN/MAX— not zero
Schema Used in This Lesson
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
product TEXT NOT NULL,
amount REAL NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT (date('now'))
);
2. Deep Dive & Implementation
Aggregate Functions
The five core aggregates:
SELECT
COUNT(*) AS total_orders, -- count all rows
COUNT(DISTINCT user_id) AS unique_customers, -- count distinct values
SUM(amount) AS total_revenue,
AVG(amount) AS average_order_value,
MIN(amount) AS smallest_order,
MAX(amount) AS largest_order
FROM orders;
COUNT(DISTINCT col) is especially useful for "how many distinct X did we see?"
GROUP BY
Group rows by one or more columns, then compute aggregates per group:
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent,
AVG(amount) AS avg_order_value
FROM orders
GROUP BY user_id
ORDER BY total_spent DESC;
GROUP BY user_id creates one result row per unique user_id. Every column in SELECT must either be in GROUP BY or be wrapped in an aggregate function — this is enforced in PostgreSQL and MySQL; SQLite is lenient but should not be relied upon.
Multiple grouping columns:
-- Revenue by product and month
SELECT
product,
strftime('%Y-%m', created_at) AS month,
COUNT(*) AS units_sold,
SUM(amount) AS revenue
FROM orders
GROUP BY product, strftime('%Y-%m', created_at)
ORDER BY revenue DESC;
HAVING vs WHERE
WHEREfilters rows before grouping — cannot use aggregate functionsHAVINGfilters after grouping — the only place you can filter on aggregate results
SELECT
user_id,
COUNT(*) AS order_count,
SUM(amount) AS total_spent
FROM orders
WHERE status = 'completed' -- only completed orders (pre-aggregation filter)
GROUP BY user_id
HAVING COUNT(*) > 5 -- only users with >5 orders (post-aggregation filter)
ORDER BY total_spent DESC;
Rule of thumb: if your filter mentions an aggregate, use HAVING. Otherwise, use WHERE.
Subqueries for Complex Aggregation
When a single GROUP BY can't solve the problem, subqueries help:
-- Find users whose total spending is above the average
SELECT
user_id,
SUM(amount) AS total_spent
FROM orders
GROUP BY user_id
HAVING SUM(amount) > (
SELECT AVG(total_per_user)
FROM (SELECT SUM(amount) AS total_per_user
FROM orders
GROUP BY user_id)
);
The inner subquery computes per-user totals; the outer query filters above-average spenders.
Window Functions
Window functions compute an aggregate over a window of rows relative to the current row — without collapsing rows like GROUP BY does.
Syntax: aggregate_or_function() OVER (PARTITION BY col ORDER BY col ROWS BETWEEN ...)
-- Revenue with running total and rank per user
SELECT
id,
user_id,
amount,
SUM(amount) OVER (PARTITION BY user_id ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)
AS running_total,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY amount DESC)
AS order_rank,
RANK() OVER (PARTITION BY user_id ORDER BY amount DESC)
AS dense_rank,
LAG(amount, 1) OVER (PARTITION BY user_id ORDER BY created_at)
AS prev_order_amount,
LEAD(amount, 1) OVER (PARTITION BY user_id ORDER BY created_at)
AS next_order_amount
FROM orders
ORDER BY user_id, created_at;
ROW_NUMBER(): unique sequential rank (1, 2, 3, ...)RANK(): gaps after ties (1, 1, 3, ...)DENSE_RANK(): no gaps (1, 1, 2, ...)LAG(col, n): value ofcoln rows before currentLEAD(col, n): value ofcoln rows after currentPARTITION BY: groups the window (likeGROUP BYbut keeps all rows)
Guided Checkpoint
Verify GROUP BY aggregation and HAVING/WHERE distinction using SQLite:
sqlite3 :memory: -header -column "
CREATE TABLE orders (id INTEGER, user_id INTEGER, amount REAL, status TEXT);
INSERT INTO orders VALUES (1, 1, 100, 'completed'), (2, 1, 200, 'completed');
INSERT INTO orders VALUES (3, 1, 50, 'pending'), (4, 2, 75, 'completed');
INSERT INTO orders VALUES (5, 2, 80, 'pending');
-- Test 1: GROUP BY — per-user totals
SELECT '=== Test 1: GROUP BY user_id ===' AS test;
SELECT user_id, COUNT(*) AS orders, SUM(amount) AS total
FROM orders GROUP BY user_id;
-- Test 2: WHERE (pre-filter) vs HAVING (post-filter)
SELECT '=== Test 2: WHERE status=completed ===' AS test;
SELECT user_id, COUNT(*) AS orders
FROM orders WHERE status = 'completed' GROUP BY user_id;
SELECT '=== Test 3: HAVING COUNT(*) > 1 ===' AS test;
SELECT user_id, COUNT(*) AS orders
FROM orders GROUP BY user_id HAVING COUNT(*) > 1;
-- Test 4: Window function — running total per user
SELECT '=== Test 4: SUM() OVER (running total) ===' AS test;
SELECT id, user_id, amount,
SUM(amount) OVER (PARTITION BY user_id ORDER BY id) AS running_total
FROM orders ORDER BY user_id, id;
"
Expected: Test 1 shows user 1 = 3 orders / 350 total, user 2 = 2 orders / 155 total. Test 2 drops pending orders. Test 3 shows only user 1 (3 orders > 1). Test 4 shows incremental running totals per user.
3. Anti-Patterns & Common Pitfalls
Anti-Pattern 1: Selecting Non-Aggregated Columns Without GROUP BY
-- Fails or gives unpredictable results in strict mode (PostgreSQL/MySQL)
SELECT id, user_id, SUM(amount) FROM orders;
-- Correct: include id in GROUP BY (if each id is unique) or remove it
SELECT user_id, SUM(amount) FROM orders GROUP BY user_id;
Anti-Pattern 2: Using WHERE to Filter on Aggregate Results
-- Invalid in all SQL dialects: WHERE cannot reference SUM()
SELECT user_id, SUM(amount)
FROM orders
WHERE SUM(amount) > 100 -- ERROR
GROUP BY user_id;
-- Correct: use HAVING
SELECT user_id, SUM(amount)
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 100;
Anti-Pattern 3: N+1 Aggregation (Multiple Queries Instead of One)
-- Bad: N queries for N users
-- for each user: SELECT SUM(amount) FROM orders WHERE user_id = :id
-- Good: one query
SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id;
Anti-Pattern 4: Missing Indexes on GROUP BY and JOIN Columns
If GROUP BY user_id is slow on a large orders table, an index on user_id (especially as a foreign key) dramatically speeds up the aggregation.
4. Independent Challenge
Build analytical queries for an e-commerce dataset.
Schema:
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT, category TEXT, price REAL);
CREATE TABLE orders (id INTEGER PRIMARY KEY, product_id INTEGER REFERENCES products(id),
quantity INTEGER, amount REAL, order_date TEXT);
Write queries that:
- Total revenue and order count per product category (sorted by revenue DESC).
- Find the top-3 best-selling products by quantity (not revenue).
- Calculate month-over-month revenue growth using
LAG()(window function). Show month, revenue, prev_month_revenue, and growth_pct. - Identify products that have never been ordered (subquery or LEFT JOIN with NULL check).
- For each category, find the product with the highest average order amount.
Save as challenge_ecommerce_analytics.sql. Test against a seeded database with at least 20 rows.
5. Consolidation & Key Invariants
Bullet-list summary of the must-remember rules.
WHEREfilters rows before grouping;HAVINGfilters groups after aggregation.COUNT(*)counts all rows;COUNT(col)counts non-NULL values only.SUM,AVG,MIN,MAXignore NULLs — a column of all NULLs returns NULL, not zero.- Every non-aggregated column in
SELECTmust appear inGROUP BYin strict SQL dialects. - Window functions keep all rows — they annotate, not collapse; use
PARTITION BYinsideOVER()as a scoped GROUP BY. RANK()vsROW_NUMBER():RANK()allows ties with gaps;ROW_NUMBER()assigns unique sequential numbers.- Subqueries and CTEs (Common Table Expressions) decompose multi-step aggregation into readable, named steps.
6. Next Steps
- SQL Joins — Revisit join types; combine
JOIN+GROUP BYfor multi-table aggregation. - SQL Schema — Design the indexes that make large-scale aggregation fast. Prerequisite: fundamentals.
- SQL Transactions — Aggregate data from transactions; see how ACID affects concurrent analytical queries.
- Backend overview — See how Prisma aggregates map to PostgreSQL
GROUP BYand window functions.
Change Log
- 2026-08-29: Migrated to 6-section canonical template (2026-08-29)
- 2026-08-29: Expanded from stub with window functions, subqueries, anti-patterns, and guided checkpoint