BUSINESS ANALYTICS • DATA WRANGLING AND QUERYING

GROUP BY & HAVING — Group and aggregate data (GROUP BY, HAVING concepts)

Transform raw transactional rows into meaningful business summaries by grouping and filtering aggregated data.

Historical Context & Motivation

Every business decision rests on summarized data — revenue by region, average order value by customer segment, or monthly headcount by department. Before the advent of relational databases, producing these summaries required manual tallying or custom programs written by IT departments. The GROUP BY clause and its companion HAVING clause were introduced as part of SQL to let analysts express these aggregation questions in a single, declarative statement. Understanding how these clauses evolved illuminates why they remain foundational to business analytics today, from dashboards in Tableau to revenue reconciliation in ERP systems.

1970
Codd's Relational Model
Edgar F. Codd at IBM publishes A Relational Model of Data for Large Shared Data Banks, proposing that data be organized in tables (relations) and manipulated via set-based operations — laying the theoretical groundwork for grouping and aggregation.
1974
SEQUEL Language Prototype
Chamberlin and Boyce at IBM develop SEQUEL (Structured English Query Language), which includes early aggregation functions and grouping syntax. This prototype directly evolves into what we now call SQL.
1986
SQL-86 (ANSI Standard)
The first ANSI/ISO SQL standard formalizes GROUP BY and HAVING as core language features. Enterprise databases from Oracle, IBM DB2, and Sybase adopt the standard, making grouped aggregation universally available to business users.
1999–Present
Modern Analytics Era
SQL-99 adds ROLLUP and CUBE extensions. Cloud warehouses like BigQuery, Snowflake, and Redshift optimize GROUP BY for terabyte-scale datasets, making real-time aggregation a staple of modern business intelligence pipelines.

The central question GROUP BY addresses is deceptively simple: How do I collapse many rows into a smaller number of summary rows, organized by one or more categorical dimensions? And HAVING follows naturally: Once I have those summaries, how do I keep only the groups that meet a specific business criterion? Answering these two questions is the gateway to virtually every reporting and analytics task in a business context.

Core Principles & Definitions

Before diving into syntax, it is essential to internalize the conceptual framework behind grouping and aggregate filtering. GROUP BY and HAVING operate on distinct logical stages of SQL query processing. Grasping these stages prevents the most common errors — such as referencing non-aggregated columns or confusing WHERE with HAVING — that plague even experienced analysts.

1

Grouping Key

The column (or columns) specified after GROUP BY that define how rows are partitioned into groups. Each unique combination of grouping-key values produces exactly one summary row in the output.
2

Aggregate Functions

Functions like SUM(), COUNT(), AVG(), MIN(), and MAX() collapse multiple values within each group into a single scalar result. They are the computational engine of GROUP BY.
3

WHERE vs. HAVING

WHERE filters individual rows before grouping. HAVING filters entire groups after aggregation. Mixing them up is the single most common mistake in business SQL.
4

SQL Logical Processing Order

SQL processes clauses in a fixed logical order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. Understanding this pipeline clarifies why certain expressions are valid only in specific clauses.
5

Non-Aggregated Column Rule

Every column in the SELECT list must either appear in the GROUP BY clause or be wrapped in an aggregate function. Violating this rule produces an error (or, in permissive modes like MySQL, unpredictable results).
KEY TAKEAWAY
Think of GROUP BY as sorting mail into mailboxes by zip code. Each mailbox (group) collects all letters (rows) with the same zip code. An aggregate function is like counting or weighing the letters in each box. HAVING is the postal inspector who removes any mailbox that fails to meet a minimum threshold — for instance, discarding boxes with fewer than 10 letters. WHERE, by contrast, is the front-desk clerk who rejects individual letters before they even reach the sorting room.

Visual Explanation — How GROUP BY Transforms Data

The diagram below illustrates the transformation pipeline of a simple sales table through GROUP BY and HAVING. On the left, you see the raw transactional rows; in the middle, the rows are partitioned into groups by region; on the right, aggregate values are computed for each group, and HAVING eliminates groups that fail the filter criterion. Follow the color-coded arrows to trace a single row through the entire process.

The pipeline shows seven raw sales rows partitioned into three groups (East, West, North). After aggregation, the HAVING clause eliminates North because its SUM (350) does not exceed 500.

Notice how the number of output rows (two, in this case) is determined entirely by the number of distinct groups that survive both the GROUP BY partition and the HAVING filter. The raw table had seven rows; the grouped table collapsed them to three; the HAVING clause pruned one group, leaving two summary rows. This telescoping effect is what makes GROUP BY so powerful for business reporting — it distills thousands or millions of transaction records into a concise, decision-ready summary.

SQL Logical Processing Order & Syntax

Although we write a SELECT statement from top to bottom, the database engine processes the clauses in a different logical order. Mastering this order is the key to understanding why certain expressions are legal in some clauses but not in others, and why HAVING can reference aggregates while WHERE cannot.

SQL LOGICAL PROCESSING ORDER
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
FROM identifies the source table(s). WHERE filters individual rows before any grouping. GROUP BY partitions surviving rows into groups. HAVING filters groups based on aggregate conditions. SELECT computes the output columns. ORDER BY sorts the result set.

GROUP BY Syntax Pattern

BASIC GROUP BY
SELECT grouping_col, AGG_FUNC(value_col) FROM table WHERE row_filter GROUP BY grouping_col;
grouping_col = one or more columns that define each group. AGG_FUNC = SUM, COUNT, AVG, MIN, MAX, or others. value_col = the column being aggregated.

Adding HAVING

GROUP BY + HAVING
SELECT grouping_col, AGG_FUNC(value_col) AS alias FROM table GROUP BY grouping_col HAVING AGG_FUNC(value_col) condition;
The HAVING clause evaluates after groups are formed. Its condition must reference an aggregate expression (e.g., HAVING COUNT(*) >= 5). In most RDBMS, you cannot use column aliases defined in SELECT within the HAVING clause; repeat the full aggregate expression instead.
⚠️ Common Pitfall
If you write WHERE SUM(amount) > 1000, the query will fail because WHERE executes before GROUP BY — aggregates do not yet exist at that stage. Use HAVING SUM(amount) > 1000 instead. Conversely, if you can filter on a non-aggregated column, always prefer WHERE for performance: it reduces the number of rows before the grouping operation.

Aggregate Functions & Multi-Column Grouping

Aggregate functions are the computational heart of GROUP BY. Each function takes a set of values within a group and returns a single result. While most business analysts rely on the "big five" — SUM, COUNT, AVG, MIN, MAX — understanding their nuances (especially around NULL handling) is critical for producing accurate reports.

Common SQL aggregate functions and their behavior
FunctionPurposeNULL HandlingBusiness Example
SUM(col)Totals numeric valuesIgnores NULLsTotal revenue per product line
COUNT(*)Counts all rows (including NULLs)Counts every rowNumber of orders per customer
COUNT(col)Counts non-NULL values in colSkips NULLsNumber of filled survey responses
AVG(col)Arithmetic meanIgnores NULLs (denominator = non-NULL count)Average sale price by region
MIN(col) / MAX(col)Smallest / largest valueIgnores NULLsEarliest / latest order date per customer

Multi-Column Grouping

Business reports frequently require grouping by more than one dimension — for example, revenue by region AND quarter. When you list multiple columns in GROUP BY, the database forms groups based on every unique combination of those columns. If there are 4 regions and 4 quarters, you could have up to 16 groups (4 × 4). Multi-column grouping is the foundation of cross-tabulation and pivot reports widely used in financial analysis and marketing analytics.

Eight source rows are partitioned into four groups based on every unique (Region, Quarter) combination. Each group produces one summary row, yielding a compact 4-row result set.
💡 COUNT(*) vs. COUNT(column)
A subtle but important distinction: COUNT(*) counts every row in the group, including rows where some columns are NULL. COUNT(email) counts only rows where the email column is not NULL. In customer analytics, this difference can significantly affect conversion rate calculations if many customers lack email addresses.

Worked Example — Quarterly Revenue Report

Imagine you are an analyst at a retail company. Your manager asks: "Show me the total revenue and average order value for each product category this year, but only include categories that generated more than $50,000 in total revenue and had at least 100 orders." Below is the SQL and a step-by-step walkthrough of the logical processing.

Quarterly Revenue by Category with HAVING Filters
1
Step 1 — Identify the source table and desired outputThe source table is orders with columns: order_id, category, order_date, amount. We want one row per category, showing total revenue and average order value.
2
Step 2 — Write the SELECT with aggregatesWe need SUM(amount) for total revenue and AVG(amount) for the average order value. We also include COUNT(*) to obtain the order count, which we will need for the HAVING filter.
SELECT category, SUM(amount) AS total_rev, AVG(amount) AS avg_order, COUNT(*) AS num_orders
3
Step 3 — Specify FROM and WHERE for the current yearWe filter rows to the current year using WHERE, because this is a row-level condition that should execute before grouping. This reduces the data set the engine must group, improving performance.
FROM orders WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01'
4
Step 4 — Add GROUP BY on the category columnThe grouping key is category. After this clause executes, each unique category value will have its own bucket of rows, ready for aggregation.
GROUP BY category
5
Step 5 — Apply HAVING to enforce business thresholdsThe manager requires total revenue > $50,000 AND at least 100 orders. Both conditions involve aggregates, so they belong in HAVING — not WHERE.
HAVING SUM(amount) > 50000 AND COUNT(*) >= 100
6
Step 6 — Assemble the complete query and add ORDER BYCombining all pieces and ordering by total revenue descending gives the final, production-ready query:
SELECT category, SUM(amount) AS total_rev, AVG(amount) AS avg_order, COUNT(*) AS num_orders FROM orders WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01' GROUP BY category HAVING SUM(amount) > 50000 AND COUNT(*) >= 100 ORDER BY total_rev DESC;

Notice how each clause maps to a specific business requirement. The WHERE clause handles the time-period filter (a property of individual rows), while the HAVING clause handles the revenue and volume thresholds (properties of groups). This separation is not merely syntactic — it reflects a fundamental difference in when the filter is evaluated in the processing pipeline. Placing conditions in the correct clause ensures correctness and often yields better query performance.

WHERE vs. HAVING — Strengths, Limitations & Best Practices

One of the most persistent sources of confusion in SQL is knowing when to use WHERE versus HAVING. While both are filter mechanisms, they operate at different stages of the query pipeline and serve fundamentally different purposes. The table below provides a side-by-side comparison that business analysts can reference as a quick decision guide.

WHERE vs. HAVING comparison
CriterionWHEREHAVING
Evaluation stageBefore GROUP BY (row-level)After GROUP BY (group-level)
Can use aggregates?No — SUM, COUNT, etc. are not yet computedYes — aggregates are the primary use case
Can filter raw columns?YesTechnically yes, but discouraged for clarity
Performance impactReduces rows before grouping — generally fasterEvaluates after grouping — more costly if many groups are formed
Typical business useFilter by date range, status, regionFilter by total revenue, average rating, order count
Required with GROUP BY?No — optionalNo — optional; used only when you need post-aggregation filtering
BEST PRACTICE
When in doubt, apply the "before or after" test. Ask yourself: "Does this condition depend on a value that exists in a single row, or does it depend on a value computed across many rows?" If the former, use WHERE. If the latter, use HAVING. This mental model maps directly to the SQL processing pipeline and will prevent errors in virtually every grouping query you write.

Connection to Advanced Grouping & Window Functions

GROUP BY and HAVING are the entry point to a broader ecosystem of SQL aggregation capabilities. As your analytics needs grow in complexity, you will encounter extensions that build on the same conceptual foundation — partitioning rows, computing aggregates, and filtering results — but with greater flexibility. Understanding where GROUP BY ends and these advanced features begin helps you choose the right tool for each analytical task.

Basic GROUP BY vs. advanced SQL aggregation features
FeatureGROUP BY (Basic)Advanced Extension
Subtotals & Grand TotalsRequires separate UNION queriesGROUP BY ROLLUP(region, quarter) adds automatic subtotals
All Dimension CombinationsMust write each combination manuallyGROUP BY CUBE(region, quarter) generates every possible subtotal
Row-Level + Aggregate in Same RowImpossible — GROUP BY collapses rowsWindow functions (SUM() OVER(PARTITION BY ...)) add aggregates without collapsing rows
Ranking Within GroupsNot directly supportedRANK() OVER(PARTITION BY category ORDER BY revenue DESC)
Running TotalsRequires self-joins or correlated subqueriesSUM(amount) OVER(ORDER BY order_date ROWS UNBOUNDED PRECEDING)

In a typical business analytics career path, you will master GROUP BY and HAVING in your first few weeks of working with SQL. Within a few months, you will begin using window functions (also called analytic functions) for tasks that require both detail-level and summary-level data in the same result set — for instance, comparing each salesperson's quarterly revenue to the department average without losing individual transaction rows. ROLLUP and CUBE, meanwhile, become essential when building financial reports that require subtotals by hierarchy. Think of GROUP BY as the solid first floor of a building upon which these more advanced floors are constructed.

Practice Problems

The following five problems test your understanding of GROUP BY and HAVING at escalating levels of difficulty. For each problem, assume you are working with a relational database that contains common business tables (orders, customers, products, employees). Try writing the SQL yourself before reading the answer.

PROBLEM 1CONCEPTUAL
Explain, in your own words, why the SQL clause WHERE COUNT(*) > 5 is invalid. Where does the error lie in terms of SQL's logical processing order, and how would you correct the query?
PROBLEM 2BASIC CALCULATION
Given a table employees(emp_id, department, salary), write a SQL query that returns the average salary for each department.
PROBLEM 3INTERMEDIATE
Using the table orders(order_id, customer_id, order_date, amount), write a query that returns the customer_id, total number of orders, and total spending for customers who placed more than 10 orders in 2024 and whose total spending exceeded $5,000. Sort the results by total spending in descending order.
PROBLEM 4APPLIED
A marketing team wants to identify product categories with declining average unit prices. Given sales(sale_id, category, sale_date, unit_price, quantity), write a query that shows each category's average unit price in Q1 2024 versus Q1 2025. Include only categories where the Q1 2025 average is lower than the Q1 2024 average. (Hint: use conditional aggregation with CASE expressions inside aggregate functions.)
PROBLEM 5CRITICAL THINKING
Consider the query: SELECT department, job_title, AVG(salary) AS avg_sal FROM employees GROUP BY department; This query runs without error in MySQL (with ONLY_FULL_GROUP_BY disabled) but fails in PostgreSQL. Explain (a) why PostgreSQL rejects it, (b) what value MySQL would return for job_title, (c) why this behavior is dangerous for business reporting, and (d) how you would fix the query if you genuinely want both dimensions.

Lesson Summary

GROUP BY partitions rows from a table into groups defined by one or more grouping keys, and each group is then collapsed into a single summary row by aggregate functions such as SUM(), COUNT(), AVG(), MIN(), and MAX(). The HAVING clause filters these groups after aggregation, keeping only those that satisfy a condition on an aggregate value. This stands in contrast to WHERE, which filters individual rows before grouping occurs. Every non-aggregated column in the SELECT list must appear in the GROUP BY clause — the non-aggregated column rule.

The SQL logical processing order (FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY) is the mental model that resolves most common errors. For multi-dimensional reporting, multi-column grouping creates groups for every unique combination of the specified columns. Looking ahead, ROLLUP, CUBE, and window functions extend GROUP BY's capabilities to handle subtotals, grand totals, and row-level-plus-aggregate analysis without collapsing detail rows.

Varsity Tutors • Business Analytics • GROUP BY & HAVING — Group and aggregate data (GROUP BY, HAVING concepts)