SQL GROUP BY — 40% Revenue Inflation from Missing Column
MySQL's lenient GROUP BY mode caused a 40% revenue inflation in reports — use these debug patterns to prevent silent data corruption in aggregate queries..
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- GROUP BY collapses rows sharing the same value into groups — each group produces one aggregate row
- Every non-aggregate SELECT column must appear in GROUP BY — this is a hard rule, not a style preference
- HAVING filters groups after aggregation — WHERE filters rows before aggregation
- GROUP BY column order matters for composite groupings but not for single-column groupings
- HAVING can reference aggregate functions; WHERE cannot
- Biggest mistake: putting an aggregate function in WHERE — it throws an error because aggregation hasn't happened yet
Imagine you have a giant pile of receipts from a store — thousands of them, one per sale. GROUP BY is like sorting those receipts into separate piles by category: all the electronics together, all the groceries together, all the clothing together. Once you have those piles, HAVING is the rule you apply to the piles themselves — 'only show me the piles worth more than $500 total.' WHERE, by contrast, would be you throwing away individual receipts before you even start sorting.
You’ve written GROUP BY a hundred times. It feels straightforward—collapse rows, sum totals, get answers. The trouble starts when you need to filter those aggregated results, and that’s where HAVING comes in. Without it, you either miss critical filters or accidentally drop rows before they ever get summed. This article unpacks exactly how HAVING works, where it fits in SQL execution, and the common traps that turn clean queries into silent bugs.
Why GROUP BY Without HAVING Is Only Half the Story
GROUP BY collapses rows into groups based on column equality, then HAVING filters those groups after aggregation. The core mechanic: GROUP BY partitions the result set, applies aggregate functions (SUM, COUNT, AVG) per partition, and HAVING acts as a WHERE clause for groups — evaluated after aggregation, not before. Without HAVING, you get all groups; with it, you keep only groups satisfying a condition on the aggregate.
In practice, GROUP BY groups on every column in the SELECT list that isn't wrapped in an aggregate. A common mistake: omitting a column from GROUP BY that appears in SELECT — SQL silently picks an arbitrary value from that column per group, inflating revenue by up to 40% in production. HAVING filters at the group level, so conditions like SUM(amount) > 1000 run after aggregation, not on individual rows. This distinction matters: WHERE filters rows before grouping, HAVING filters groups after.
Use GROUP BY + HAVING when you need to answer questions like "which customers spent more than $10k total?" or "which products had fewer than 5 returns?" — any query where the filter depends on the aggregate result. In real systems, this pattern is essential for reporting, anomaly detection, and data quality checks. Without HAVING, you'd either over-report or have to subquery, which is slower and harder to read.
GROUP BY: Collapsing Rows Into Meaningful Summaries
GROUP BY tells the database to treat all rows that share the same value in a column as a single unit, then apply an aggregate function — SUM, COUNT, AVG, MAX, MIN — across that unit. The result set has one row per unique group, not one row per original record.
This is critical to internalise: after a GROUP BY, every column in your SELECT must either be the grouped column itself or wrapped in an aggregate function. If you select a non-grouped, non-aggregated column the database doesn't know which of the many original rows' values to display. Some databases (MySQL with loose mode) will silently pick a random row's value. PostgreSQL and SQL Server will flat-out error. Neither outcome is what you want.
The mental model that helps most: picture the database first sorting all rows by the GROUP BY column, then drawing a horizontal line between each new value, then running your aggregate function on each block between the lines. You only ever see the aggregated result per block — the individual rows are gone from view.
HAVING: Filtering Groups After Aggregation (Not Before)
Here's the question that unlocks HAVING: once you've run GROUP BY and computed SUM(amount) per category, how do you show only categories where that total exceeds $50,000? You can't use WHERE — by the time WHERE runs, the aggregation hasn't happened yet. WHERE is a row-level filter applied to the raw table. HAVING is a group-level filter applied to the aggregated output.
Think of the SQL execution order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. WHERE filters raw rows before they enter the grouping step. HAVING filters the grouped rows after aggregation is complete. This is why you can reference SUM(amount) in HAVING but not in WHERE.
HAVING is also where you enforce data quality thresholds. 'Show me customers, but only if they've placed at least 5 orders' is a HAVING condition — COUNT(order_id) >= 5. It's common to combine both WHERE and HAVING in one query: WHERE removes irrelevant raw rows early (which improves performance by feeding fewer rows into the grouping step), and HAVING then prunes the resulting groups.
Real-World Patterns: How GROUP BY and HAVING Are Actually Used
In production code these clauses rarely appear in isolation. Here are three patterns you'll encounter constantly and should be able to write from memory.
The first is the top-N per group pattern: find the most active customers, highest-revenue regions, or most common error codes. You GROUP BY the entity, aggregate a metric, HAVING optionally filters noise, then ORDER BY the metric with LIMIT to get your top results.
The second is the data quality audit pattern: find anomalies by grouping records that should be unique and counting how many duplicates exist. A COUNT(*) > 1 in HAVING is the classic duplicate-detection query every data engineer writes at some point.
The third is the cohort threshold pattern common in analytics: 'show me all users who took action X at least N times.' This is GROUP BY user_id, HAVING COUNT(*) >= N — the backbone of retention and engagement reports. Once you recognise these shapes, you stop treating GROUP BY and HAVING as separate features and start seeing them as a single analytical toolset.
Common Mistakes That Silently Break Your Queries
Most GROUP BY and HAVING bugs don't throw errors — they return wrong results that look plausible, which makes them especially dangerous. These are the ones that catch experienced developers off guard, not just beginners.
The first is using WHERE where you need HAVING, or vice versa. It sounds obvious once you know the execution order, but under deadline pressure it's easy to write WHERE SUM(amount) > 50000 and then be confused when the database throws an error about aggregate functions not being allowed in WHERE.
The second is including a column in SELECT that isn't in GROUP BY and isn't aggregated — discussed earlier, but worth re-emphasising because MySQL's lenient default mode will run the query and silently return a non-deterministic value from one of the grouped rows.
The third is a subtler one: filtering on a column alias in HAVING. Because HAVING runs after SELECT in the logical execution order (but before the alias is fully materialised in most engines), referencing an alias defined in SELECT often fails. You need to repeat the expression.
Window Functions vs. GROUP BY: When HAVING Steals Your Shine
Junior devs treat GROUP BY like the only hammer for summary work. That's wrong. HAVING filters groups, yes, but it also destroys row-level context. If you need a summary alongside original data, HAVING forces you into two queries or a messy self-join. Window functions keep the rows and let you filter on aggregate logic without collapsing the set. Why does this matter? Production dashboards and audit logs routinely need things like 'show all orders from customers whose lifetime spend is over $10k'. With GROUP BY + HAVING you lose the individual order rows. With a windowed SUM + QUALIFY or WHERE clause, you don't. Understand the cost: HAVING trades data resolution for simplicity. That's fine for canned reports. Dangerous for debugging. I've seen teams spend days rebuilding query logic because a HAVING clause erased the transaction detail they needed for an outage postmortem. Pick the tool for the job. If you need both worlds — group-level filters and row-level detail — window functions are your escape hatch.
Order of Execution: Why HAVING Can't See Your Aliases
You wrote a beautiful GROUP BY query, aliased your aggregate, then used that alias in HAVING. It threw an error. You cursed the database. Here's the fix: SQL's logical order of execution runs HAVING before SELECT. That means HAVING only sees the original column names, not your fresh alias. This trips up everyone at least once in production. I've debugged three separate incidents where engineers wrote HAVING total_revenue > 5000 and wondered why the parser refused, then slapped the aggregate expression back in and moved on without understanding why. The WHY is the execution order: FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT. HAVING evaluates on grouped rows before SELECT assigns its projection aliases. The HOW is simple: duplicate the full aggregate expression in HAVING, or use a subquery/CTE. The duplicate isn't elegant, but it's explicit. The subquery is cleaner for complex cases. Pick one, document why, and move on. This isn't a deficiency — it's a deliberate design choice that enforces discipline at the expense of convenience.
GROUP BY Beyond Aggregates: Using HAVING for Data Quality Checks
Most devs see HAVING only as a revenue filter. That's narrow. HAVING is your first line of defense for data quality in pipelines. Think about it: you can detect orphan records, null heaps, and inconsistent cardinalities with a single GROUP BY + HAVING pass before any business logic touches the data. Why should care? Because dirty data that hits a dashboard or training pipeline costs hours of retroactive cleanup — or worse, wrong decisions. I've used HAVING COUNT(*) = 1 to find duplicate primary keys. HAVING MIN(date) = MAX(date) to spot no-change batches. HAVING COUNT(DISTINCT status) > 1 to catch row-level state corruption. This pattern runs fast on indexed GROUP BY columns and catches anomalies at ingestion time. It's cheap insurance. The HOW: wrap your raw table in a GROUP BY on the suspect column, then HAVING on a diagnostic aggregate. No joins, no subqueries, just raw detection. Attach it as a pre-step in your ETL or scheduled data health check. I've caught staging tables with null foreign keys in production on day one using exactly this. Your data is only as good as your willingness to distrust it.
HAVING COUNT(*) > 1 on primary keys catches duplication before it pollutes downstream models. It's ten lines of SQL that have saved me weeks of data recovery.Why COUNT(*) and COUNT(column) Are Not the Same Thing
I still see devs treat COUNT(*) and COUNT(column) like interchangeable aliases. They're not. And the difference will bite you in production.
COUNT(*) counts every row in the group — including rows where every column is NULL. It's the total row count, period. COUNT(column) only counts non-NULL values in that specific column. If your column has NULLs, you're silently dropping data from your aggregation.
In a GROUP BY with HAVING, this matters. Say you're counting orders per customer. COUNT(order_id) and COUNT() give the same result only if order_id is NOT NULL. If a row exists but the order_id is NULL (maybe a failed transaction), COUNT() includes it; COUNT(order_id) doesn't. Your HAVING filter now depends on which COUNT you chose.
Always ask: do I want the count of rows that exist, or the count of rows with a meaningful value? Pick the right COUNT before it picks your database apart.
Mastering GROUP BY: The Two Examples You'll Use Every Week
Every production query I write falls into one of two patterns: aggregate by a dimension, then filter groups. Here are the two examples that cover 80% of use cases.
First: total quantity sold per product. Straightforward GROUP BY on product_id, SUM(quantity). Need to see only products that moved? Add HAVING. This is your bread-and-butter for inventory dashboards and sales reports.
Second: countries with revenue over $2000. Same pattern — GROUP BY country, SUM(revenue), then HAVING SUM(revenue) > 2000. The WHERE clause filters rows before aggregation (e.g., only completed orders). HAVING filters after aggregation (only countries that hit the revenue bar). This distinction is why your queries run fast instead of crawling.
Both examples look simple. That's the point. The complexity people add with subqueries and CTEs is usually just poorly placed filters. Get GROUP BY and HAVING right at this basic level, and you'll solve most aggregation problems before they become incidents.
GROUP BY with ROLLUP, CUBE, GROUPING SETS
Standard GROUP BY produces one row per unique combination of grouping columns. But what if you need subtotals and grand totals in the same result set? That's where ROLLUP, CUBE, and GROUPING SETS come in. These extensions allow you to generate multiple grouping levels in a single query, saving you from writing multiple UNION ALL queries.
ROLLUP creates a hierarchy of subtotals from the most detailed level up to a grand total. For example, GROUP BY ROLLUP (year, month) produces totals per month, per year, and a grand total. The order of columns matters: ROLLUP assumes a left-to-right hierarchy.
CUBE generates all possible combinations of the grouping columns, including subtotals for every subset. For two columns, CUBE gives you four grouping sets: (A, B), (A), (B), and (). This is useful for cross-tabulation reports.
GROUPING SETS lets you explicitly define which grouping sets you want. For instance, GROUP BY GROUPING SETS ((year, month), (year), ()) gives you monthly totals, yearly totals, and a grand total. This is the most flexible option.
To distinguish subtotal rows from detail rows, use the G function. It returns 1 when the column is aggregated (i.e., part of a subtotal) and 0 otherwise. This is crucial for labeling rows correctly in reports.ROUPING()
Example: Suppose you have a sales table with year, month, and amount. To get monthly totals, yearly totals, and a grand total with clear labels:
``sql SELECT CASE WHEN GROUPING(year) = 1 THEN 'All Years' ELSE CAST(year AS TEXT) END AS year, CASE WHEN GROUPING(month) = 1 THEN 'All Months' ELSE month END AS month, SUM(amount) AS total_sales FROM sales GROUP BY ROLLUP (year, month); ``
This query returns rows like (2024, Jan, 1000), (2024, Feb, 1500), (2024, All Months, 2500), (All Years, All Months, 5000).
Performance note: These extensions can be expensive on large datasets because they compute multiple aggregations. Use them judiciously and consider materialized views for repeated reports.
FILTER Clause for Conditional Aggregation (PostgreSQL)
Standard SQL requires you to use CASE expressions inside aggregate functions to perform conditional aggregation, like SUM(CASE WHEN condition THEN value END). PostgreSQL offers a cleaner alternative: the FILTER clause. FILTER allows you to specify which rows to include in an aggregate function directly, making queries more readable and often more efficient.
Syntax: aggregate_function(expression) FILTER (WHERE condition)
For example, to count only active users and sum only high-value orders:
``sql SELECT COUNT(*) FILTER (WHERE status = 'active') AS active_users, SUM(amount) FILTER (WHERE amount > 100) AS high_value_sum FROM orders; ``
This is equivalent to:
``sql SELECT COUNT(CASE WHEN status = 'active' THEN 1 END) AS active_users, SUM(CASE WHEN amount > 100 THEN amount END) AS high_value_sum FROM orders; ``
FILTER is especially powerful when combined with GROUP BY. You can compute multiple conditional aggregates per group without cluttering the SELECT list with CASE expressions.
Example: For each product category, calculate total sales and sales from premium customers:
``sql SELECT category, SUM(amount) AS total_sales, SUM(amount) FILTER (WHERE customer_tier = 'premium') AS premium_sales FROM orders GROUP BY category; ``
FILTER is supported in PostgreSQL 9.4+ and is part of the SQL standard (ISO/IEC 9075:2016). It works with all aggregate functions: COUNT, SUM, AVG, MIN, MAX, and even ordered-set aggregates like percentile_cont.
Performance: In PostgreSQL, FILTER can be faster than CASE because the planner can optimize it better, especially when multiple FILTER conditions are disjoint. However, the difference is usually negligible on small datasets.
Limitations: FILTER cannot be used with window functions. For window functions, you must use CASE or the FILTER clause is not supported (depending on the database). Also, not all databases support FILTER; it's a PostgreSQL feature that has been adopted by some others (e.g., SQLite 3.30+).
HAVING vs WHERE vs QUALIFY: Filtering at Different Stages
Understanding when to use WHERE, HAVING, and QUALIFY is crucial for writing correct and efficient SQL. Each filters rows at a different stage of query execution.
WHERE filters rows before grouping and aggregation. It operates on individual rows from the FROM clause. Use WHERE to exclude rows that shouldn't participate in aggregation. For example, WHERE status = 'active' ensures only active orders are aggregated.
HAVING filters groups after aggregation. It operates on the result of GROUP BY and can reference aggregate functions. Use HAVING to exclude entire groups based on aggregated values. For example, HAVING SUM(amount) > 1000 keeps only groups with total sales over 1000.
QUALIFY is a clause used in some databases (like Snowflake, BigQuery, Teradata) to filter the results of window functions. It is evaluated after window functions are computed but before ORDER BY. QUALIFY is not part of standard SQL but is widely used in cloud data warehouses. For example, QUALIFY keeps only the latest row per user.ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) = 1
Execution order: FROM -> WHERE -> GROUP BY -> HAVING -> WINDOW -> QUALIFY -> SELECT -> DISTINCT -> ORDER BY -> LIMIT. This order explains why you can't use column aliases in WHERE or HAVING (they are defined in SELECT, which comes later).
Common mistake: Using HAVING for conditions that could be in WHERE. This is inefficient because HAVING filters after aggregation, meaning more rows are processed. Always push filters to WHERE when possible.
Example: Find categories with average order value > 50 for premium customers:
``sql SELECT category, AVG(amount) AS avg_order FROM orders WHERE customer_tier = 'premium' -- filter rows before aggregation GROUP BY category HAVING AVG(amount) > 50; -- filter groups after aggregation ``
QUALIFY example (Snowflake): Get the top 5 products by sales per region:
``sql SELECT region, product, SUM(sales) AS total_sales, ``ROW_NUMBER() OVER (PARTITION BY region ORDER BY SUM(sales) DESC) AS rn FROM orders GROUP BY region, product QUALIFY rn <= 5;
Note that QUALIFY is evaluated after GROUP BY and window functions, so you can use aggregate results in the window function and then filter with QUALIFY.
Monthly Revenue Report Inflated by 40% Due to Missing GROUP BY Column
- Enable ONLY_FULL_GROUP_BY in MySQL — lenient mode silently returns wrong data for ambiguous GROUP BY
- Every non-aggregate SELECT column must be in GROUP BY — verify this for every GROUP BY query in code review
- Compare aggregate query output against raw data exports as a post-deployment sanity check
| File | Command / Code | Purpose |
|---|---|---|
| sales_by_category.sql | SELECT | GROUP BY |
| high_value_categories.sql | SELECT | HAVING |
| real_world_groupby_patterns.sql | SELECT | Real-World Patterns |
| groupby_mistakes_and_fixes.sql | SELECT category, SUM(amount) AS total_revenue | Common Mistakes That Silently Break Your Queries |
| WindowVsGroupBy_Having.sql | SELECT | Window Functions vs. GROUP BY |
| HavingAliasExecutionOrder.sql | SELECT | Order of Execution |
| DataQualityCheck_Having.sql | SELECT | GROUP BY Beyond Aggregates |
| CountStarVsColumn.sql | SELECT | Why COUNT(*) and COUNT(column) Are Not the Same Thing |
| Example.sql | SELECT product_id, SUM(quantity) AS total_sold | Mastering GROUP BY |
| rollup_example.sql | SELECT | GROUP BY with ROLLUP, CUBE, GROUPING SETS |
| filter_clause.sql | SELECT | FILTER Clause for Conditional Aggregation (PostgreSQL) |
| filtering_stages.sql | SELECT | HAVING vs WHERE vs QUALIFY |
Key takeaways
Interview Questions on This Topic
What is the difference between WHERE and HAVING, and can you use them together?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Drawn from code that ran under real load.
That's SQL Basics. Mark it forged?
11 min read · try the examples if you haven't