Aggregate Rows with GROUP BY
Learn to aggregate rows with GROUP BY in PostgreSQL—understand the mental model, see step-by-step examples, and troubleshoot common issues.
Focus: aggregate rows with group by
You've built tables, written queries, and filtered data—but the moment your analysis demands answers like "How many orders did each customer place?" or "What's the average salary per department?", raw rows don't give you the story. That's where PostgreSQL's GROUP BY clause steps in to transform scattered records into meaningful summaries. In this lesson, you'll not only learn the syntax but also develop an intuition for when and how to aggregate rows with GROUP BY, turning messy data into valuable insights.
The problem this lesson solves
Handling thousands of raw transaction rows is overwhelming and unhelpful for decision-making. Without GROUP BY, you'd have to manually count or compute totals outside the database, leading to errors, inefficiency, and stale answers. For example, calculating the total sales per region from a million-row orders table in Python would require transferring all that data—a disaster for performance and memory.
Pain point: Without aggregation, your queries return too much detail, making it impossible to see patterns or draw conclusions quickly.
PostgreSQL's GROUP BY lets the database engine do the heavy lifting: collapsing rows that share a common value (like customer_id or region) and applying an aggregate function—COUNT, SUM, AVG, MIN, MAX—to each group. The result is a compact, insightful summary without sacrificing performance.
Core concept / mental model
Think of GROUP BY as a sorting hat for your data. It examines every row in the result set (after any WHERE filtering) and sorts them into buckets based on one or more columns. Then, one aggregate function runs against each bucket, producing a single output row per bucket. The output row contains the grouping column(s) and the calculated aggregate value.
A diagram in words
Raw orders table (sample):
| customer_id | amount |
|---|---|
| 1 | 100 |
| 1 | 250 |
| 2 | 75 |
| 1 | 150 |
| 2 | 200 |
After SELECT customer_id, COUNT(*) FROM orders GROUP BY customer_id;:
| customer_id | count |
|---|---|
| 1 | 3 |
| 2 | 2 |
Each GROUP BY bucket aggregates rows with the same customer_id. The COUNT(*) counts rows per bucket—output one row per group.
Key definitions
- Grouping column(s): Columns listed in the
GROUP BYclause—they define the buckets. - Aggregate function: Operates on a group of rows to return a single value:
COUNT,SUM,AVG,MIN,MAX,array_agg,string_agg, etc. - Result set: After grouping, each row corresponds to one unique combination of grouping column values.
How it works step by step
Follow these logical steps the PostgreSQL engine takes (conceptually) when processing a GROUP BY query:
- Filter rows (
WHERE) — The database first applies anyWHEREconditions to exclude rows before grouping. This is crucial:WHEREruns before aggregation, so you cannot use aggregate functions inWHERE(useHAVINGinstead). - Group remaining rows — Rows are split into groups based on the
GROUP BYcolumns. A group exists for each distinct combination of those column values. - Aggregate each group — For each group, the engine evaluates the aggregate functions (e.g.,
SUM(amount),COUNT(*)) across all rows in that group. - Project output columns — The
SELECTlist can include grouping columns, aggregate expressions, or constant expressions—but cannot include non-grouped, non-aggregated columns (PostgreSQL will raise an error). - Filter groups (
HAVING) — If present,HAVINGremoves whole groups based on a condition that often references aggregate results (e.g.,HAVING COUNT(*) > 10). - Order results (
ORDER BY) — Finally, rows are sorted (if requested). You can order by grouping columns or aggregate expressions (aliases allowed).
Execution order reminder
The logical order is: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. This explains why WHERE can't use aliases from SELECT—the alias isn't defined until later.
Hands-on walkthrough
Let's create a sample table and practice aggregating rows with GROUP BY. We'll use a simple sales table for a retail store.
-- Create sample sales table
CREATE TABLE sales (
id SERIAL PRIMARY KEY,
product TEXT NOT NULL,
region TEXT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
sale_date DATE NOT NULL
);
-- Insert sample data
INSERT INTO sales (product, region, amount, sale_date) VALUES
('Laptop', 'North', 1200.00, '2024-01-15'),
('Laptop', 'South', 1100.00, '2024-01-16'),
('Mouse', 'North', 25.00, '2024-01-15'),
('Mouse', 'South', 30.00, '2024-01-16'),
('Laptop', 'North', 1250.00, '2024-02-10'),
('Keyboard','South', 70.00, '2024-02-12'),
('Mouse', 'North', 28.00, '2024-02-11');
Example 1: Count sales per product
SELECT product, COUNT(*) AS total_sales
FROM sales
GROUP BY product;
Output:
product | total_sales
-----------+-------------
Laptop | 3
Mouse | 3
Keyboard | 1
Example 2: Total sales amount per region
SELECT region, SUM(amount) AS total_revenue
FROM sales
GROUP BY region;
Output:
region | total_revenue
--------+---------------
North | 2503.00
South | 1200.00
Example 3: Group by multiple columns
Group by both product and region to see breakdowns:
SELECT product, region, COUNT(*) AS cnt, AVG(amount) AS avg_amount
FROM sales
GROUP BY product, region
ORDER BY product, region;
Output:
product | region | cnt | avg_amount
-----------+--------+-----+-------------------
Keyboard | South | 1 | 70.00
Laptop | North | 2 | 1225.00
Laptop | South | 1 | 1100.00
Mouse | North | 2 | 26.50
Mouse | South | 1 | 30.00
Example 4: Filter groups with HAVING
Show only products that appear in more than 1 sale:
SELECT product, COUNT(*) AS cnt
FROM sales
GROUP BY product
HAVING COUNT(*) > 1;
Output:
product | cnt
---------+-----
Laptop | 3
Mouse | 3
Pro tip: Use
HAVINGto filter groups based on aggregate values; useWHEREto filter rows before grouping.
Compare options / when to choose what
When you need aggregated data, you have several SQL constructs. This table compares them:
| Construct | Purpose | Example | When to choose |
|---|---|---|---|
GROUP BY |
Group rows and apply aggregate functions per group | SELECT dept, COUNT(*) FROM emp GROUP BY dept; |
When you need summary per distinct value in one or more columns |
HAVING |
Filter groups after aggregation | GROUP BY dept HAVING COUNT(*) > 5 |
To exclude groups based on aggregate condition |
WHERE |
Filter rows before grouping | WHERE salary > 50000 |
To narrow down which rows participate in aggregation |
DISTINCT |
Remove duplicate rows, no aggregation | SELECT DISTINCT region FROM sales; |
When you just need unique values, no counts or sums |
Window functions (OVER (PARTITION BY ...)) |
Keep all rows and add aggregate values alongside | AVG(amount) OVER (PARTITION BY region) |
When you need the detail rows and the aggregate value in the same result |
Variations
GROUPING SETS,ROLLUP,CUBE— PostgreSQL supports advanced grouping for multiple subtotal levels in one query (e.g.,ROLLUP (region, product)gives totals per region and grand total).FILTERclause on aggregates — You can conditionally include rows in a specific aggregate:COUNT(*) FILTER (WHERE amount > 100).array_aggorstring_agg— Instead of numeric aggregates, you can collect values into arrays or strings per group.
Troubleshooting & edge cases
Aggregating rows with GROUP BY is powerful, but it comes with common pitfalls:
1. Selecting a non-grouped column
-- ERROR: column "sales.product" must appear in the GROUP BY clause or be used in an aggregate function
SELECT product, region, COUNT(*) FROM sales GROUP BY product;
Fix: Include region in GROUP BY or apply an aggregate like MAX(region).
2. Using WHERE with aggregate functions
-- ERROR: aggregate functions are not allowed in WHERE
SELECT product, COUNT(*) FROM sales WHERE COUNT(*) > 1 GROUP BY product;
Fix: Use HAVING COUNT(*) > 1.
3. NULL values in grouping column
Rows with NULL in the grouping column are grouped together (they form their own group). For example, if region is NULL, those rows sit in one bucket.
4. Misunderstanding COUNT(*) vs COUNT(column)
COUNT(*)counts all rows, including those withNULLs.COUNT(region)counts only non-null values inregion.
5. Ordering by aggregate alias
PostgreSQL allows ORDER BY on aliases, but be careful when using ORDER BY on non-aggregated columns—it will fail if not in GROUP BY.
6. Performance with large tables
GROUP BY often requires sorting or hashing. Use indexes on grouping columns to speed up. Check query plan with EXPLAIN.
What you learned & what's next
You've unlocked the power to aggregate rows with GROUP BY in PostgreSQL. Now you can:
- Explain the core concept: grouping rows into buckets and applying aggregate functions.
- Write queries using
GROUP BYwith single and multiple columns. - Filter groups using
HAVINGand differentiate it fromWHERE. - Handle common errors and edge cases like non-grouped columns and
NULLs.
Next step: In the next lesson, you'll dive into window functions, which let you compute aggregations without collapsing rows—giving you the best of both worlds. You'll use OVER (PARTITION BY ...) to see running totals, rankings, and moving averages. Get ready to take your analytics to the next level.
Practice recap
Practice by creating a simple orders table and writing queries to find total sales per customer and number of orders per product, using both COUNT and SUM. Then try filtering groups with HAVING to see only groups that exceed a threshold, and experiment with grouping by multiple columns to get refined summaries. This solidifies your understanding before moving to window functions.
Common mistakes
- Trying to reference a non-aggregated, non-grouped column in SELECT—PostgreSQL raises an error; always include such columns in GROUP BY or wrap them in an aggregate function.
- Using aggregate functions inside the WHERE clause—this is illegal; use HAVING for filtering groups after aggregation.
- Confusing COUNT() with COUNT(column): COUNT() includes NULLs, while COUNT(column) count only non-null values.
- Omitting GROUP BY when using aggregate functions with non-aggregated columns—PostgreSQL returns one row for the whole table, which may not be what you want.
Variations
- Use ROLLUP or CUBE to generate multiple levels of subtotals in a single query (e.g., total per product and region + grand total).
- Use the FILTER clause on aggregates to include only certain rows: SUM(amount) FILTER (WHERE region = 'North').
- For string aggregation, use string_agg(column, ', ') instead of numeric aggregates to produce comma-separated lists per group.
Real-world use cases
- E-commerce dashboards showing daily orders and revenue per product using GROUP BY over orders table.
- HR reporting: average salary, headcount per department from employees table.
- Web analytics: page views per URL and total session duration per user group from logs.
Key takeaways
- GROUP BY creates buckets from rows sharing the same values in specified columns.
- Aggregate functions (COUNT, SUM, AVG, MIN, MAX) compute a single value per bucket.
- WHERE filters rows before grouping; HAVING filters groups after aggregation.
- Any selected column must be a grouping column or inside an aggregate function.
- NULL values in grouping columns form their own group, and COUNT(*) vs COUNT(col) behave differently with NULLs.
- Use indexes on grouping columns to optimize GROUP BY on large tables.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.