Mid-level 9 min · March 05, 2026

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..

N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
✦ Definition~90s read
What is SQL GROUP BY and HAVING?

GROUP BY is a SQL clause that collapses multiple rows into summary rows based on shared column values, enabling aggregate functions like SUM(), COUNT(), and AVG() to compute per-group metrics. It exists because raw transactional data is too granular for business decisions—you need totals per customer, averages per region, or counts per product category.

Imagine you have a giant pile of receipts from a store — thousands of them, one per sale.

Without GROUP BY, you'd be stuck scanning millions of rows manually or writing nested subqueries that kill performance. It's the backbone of every reporting query, but it's also where silent data corruption happens: if you SELECT a column that isn't in the GROUP BY clause and isn't wrapped in an aggregate, most databases (MySQL excluded by default) will error out, but some will return arbitrary values from the first row in each group, inflating revenue by 40% or more when you accidentally omit a dimension like store_id from the grouping set.

HAVING is the complementary filter that runs after aggregation, unlike WHERE which filters rows before grouping. This distinction is critical: WHERE can't reference aggregate results like SUM(amount) > 10000 because those values don't exist yet. HAVING exists precisely to solve that gap—it's the gatekeeper for groups, letting you discard entire customer segments or time periods after their totals are computed.

In practice, you'll see patterns like GROUP BY department HAVING COUNT(*) > 5 to find teams with enough headcount, or GROUP BY product_id HAVING SUM(revenue) > 100000 to isolate top performers. The trap is using HAVING where WHERE would be faster: filtering on non-aggregated columns (e.g., HAVING status = 'active') is wasteful because you're filtering after grouping instead of before, forcing the database to aggregate rows you'll immediately discard.

Real-world usage often combines GROUP BY with window functions for layered analysis—for example, grouping sales by month with GROUP BY, then using RANK() OVER (PARTITION BY region ORDER BY total_sales DESC) to find top performers within each group. The choice between GROUP BY and window functions hinges on whether you need row-level detail alongside aggregates: GROUP BY destroys row granularity, while window functions preserve it.

When you need both, you'll frequently use GROUP BY to build a summary table, then join it back to the original data—or use HAVING to filter those summaries before the join. Common mistakes include forgetting to include all non-aggregated columns in the GROUP BY clause (the silent inflation bug), using HAVING on indexed columns that should be in WHERE, and confusing HAVING with WHERE when filtering on computed columns like revenue - cost—that expression must be repeated in HAVING or aliased in a subquery.

Plain-English First

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.

Missing Column in GROUP BY
Omitting a non-aggregated column from GROUP BY doesn't error in many databases — it silently picks one value per group, silently inflating aggregates.
Production Insight
A finance dashboard grouped sales by region but forgot to include 'product_category' in GROUP BY — revenue per region was inflated by 40% because multiple categories were collapsed into one row per region.
The symptom: totals matched source data at the company level, but per-region breakdowns were wrong and inconsistent across refreshes.
Rule: Every non-aggregated column in SELECT must appear in GROUP BY — or you're asking the database to lie to you.
Key Takeaway
GROUP BY collapses rows; HAVING filters the collapsed groups — they are not interchangeable with WHERE.
A missing column in GROUP BY silently corrupts aggregates — always list every non-aggregate SELECT column.
HAVING runs after aggregation, WHERE before — use WHERE for row filters, HAVING for group filters.
SQL GROUP BY & HAVING: Avoiding Revenue Inflation THECODEFORGE.IO SQL GROUP BY & HAVING: Avoiding Revenue Inflation Flow from aggregation to filtered groups with execution order GROUP BY Collapses Rows Aggregates per group, e.g., SUM(revenue) Missing Column in GROUP BY Causes unintended row merging, inflating revenue HAVING Filters Groups Applies after aggregation, e.g., HAVING SUM > 100 Execution Order Matters HAVING cannot see SELECT aliases Window Functions Alternative Use with HAVING for per-group ranking Accurate Aggregated Output Correct grouping and filtering prevent inflation ⚠ Omitting non-aggregated column from GROUP BY silently merges rows Always include all non-aggregated SELECT columns in GROUP BY THECODEFORGE.IO
thecodeforge.io
SQL GROUP BY & HAVING: Avoiding Revenue Inflation
Sql Group By Having

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.

sales_by_category.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- Sample table: orders
-- Columns: order_id, customer_id, category, amount, order_date

-- ── BASIC GROUP BY ──────────────────────────────────────────────
-- Goal: find total revenue and number of orders per product category

SELECT
    category,                        -- the grouping column — one row per unique value
    COUNT(order_id)   AS order_count, -- how many orders fell into this group
    SUM(amount)       AS total_revenue,-- aggregate: sum of all amounts in the group
    ROUND(AVG(amount), 2) AS avg_order_value -- aggregate: mean order size
FROM orders
GROUP BY category                    -- collapse every category into one summary row
ORDER BY total_revenue DESC;         -- show highest-revenue category first

-- ── GROUPING BY MULTIPLE COLUMNS ────────────────────────────────
-- Goal: break revenue down by category AND by the year of the order
-- This gives one row per (category, year) combination

SELECT
    category,
    EXTRACT(YEAR FROM order_date) AS order_year, -- derive year from the date column
    COUNT(order_id)               AS order_count,
    SUM(amount)                   AS total_revenue
FROM orders
GROUP BY
    category,               -- group first by category...
    EXTRACT(YEAR FROM order_date) -- ...then by year within each category
ORDER BY order_year, total_revenue DESC;
Output
-- Result of first query (single GROUP BY):
category | order_count | total_revenue | avg_order_value
--------------+-------------+---------------+----------------
Electronics | 142 | 84350.00 | 594.01
Furniture | 89 | 61200.00 | 687.64
Clothing | 310 | 31450.00 | 101.45
Groceries | 503 | 18900.00 | 37.57
-- Result of second query (multi-column GROUP BY):
category | order_year | order_count | total_revenue
------------+------------+-------------+--------------
Clothing | 2023 | 158 | 15800.00
Electronics | 2023 | 71 | 41200.00
Furniture | 2023 | 44 | 30100.00
Clothing | 2024 | 152 | 15650.00
Electronics | 2024 | 71 | 43150.00
Furniture | 2024 | 45 | 31100.00
Watch Out: The Non-Aggregated Column Trap
If you SELECT a column that isn't in your GROUP BY clause and isn't wrapped in an aggregate, PostgreSQL throws 'column must appear in the GROUP BY clause or be used in an aggregate function.' MySQL in its default mode silently picks an arbitrary row's value — which means you get results that look correct but aren't. Always be explicit: either group by it or aggregate it.
Production Insight
GROUP BY on an unindexed column forces a hash aggregate or sort on the full table — expensive at scale.
Add an index on your most frequent GROUP BY column for repeated report queries: CREATE INDEX idx_orders_status ON orders(status).
For time-series reports, group by DATE_TRUNC('month', created_at) and index the created_at column.
Key Takeaway
GROUP BY is expensive without an index on the grouping column — add indexes for repeated aggregation queries.
NULL values in the GROUP BY column form their own group — decide explicitly whether to include or exclude them.
Every non-aggregate SELECT column must be in GROUP BY — this is SQL's strictest grouping rule.

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.

high_value_categories.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
-- ── HAVING: filter groups by their aggregate value ──────────────
-- Goal: find product categories that have generated over $50,000
-- and had more than 100 individual orders

SELECT
    category,
    COUNT(order_id)  AS order_count,
    SUM(amount)      AS total_revenue
FROM orders
GROUP BY category
HAVING
    SUM(amount)     > 50000   -- only keep groups where the SUM clears this bar
    AND COUNT(order_id) > 100 -- AND the group contains more than 100 orders
ORDER BY total_revenue DESC;


-- ── WHERE + GROUP BY + HAVING together ──────────────────────────
-- Goal: same report, but exclude any orders placed before 2023
-- (WHERE trims raw rows BEFORE grouping — faster than filtering in HAVING)

SELECT
    category,
    COUNT(order_id)  AS order_count,
    SUM(amount)      AS total_revenue
FROM orders
WHERE order_date >= '2023-01-01'  -- row-level filter: remove old orders FIRST
GROUP BY category
HAVING
    SUM(amount)     > 50000        -- group-level filter: applied AFTER aggregation
    AND COUNT(order_id) > 100
ORDER BY total_revenue DESC;


-- ── HAVING without GROUP BY (edge case worth knowing) ────────────
-- HAVING can technically apply to the entire table as one group.
-- This returns the row only if the whole orders table has > 1000 rows.
-- Rarely useful, but interviewers love asking if it's valid SQL.

SELECT COUNT(*) AS total_orders
FROM orders
HAVING COUNT(*) > 1000;
Output
-- Result of first query:
category | order_count | total_revenue
------------+-------------+--------------
Electronics | 142 | 84350.00
Furniture | 89 | 61200.00
-- (Clothing had 310 orders but only $31,450 revenue — fails the $50k HAVING check)
-- (Groceries fails both conditions)
-- Result of WHERE + GROUP BY + HAVING query (2023 data only):
category | order_count | total_revenue
------------+-------------+--------------
Electronics | 142 | 84350.00
Furniture | 89 | 61200.00
-- Result of HAVING without GROUP BY:
total_orders
------------
1044
-- (Returns the count row because 1044 > 1000 — if < 1000, returns empty set)
Pro Tip: Push Filters Into WHERE When You Can
If a condition doesn't involve an aggregate (no SUM, COUNT, AVG, etc.), put it in WHERE — not HAVING. WHERE filters rows before the database does the expensive grouping work. HAVING filters after. A condition like HAVING category = 'Electronics' works, but WHERE category = 'Electronics' does the same job with less computation because the database skips non-Electronics rows entirely before it starts grouping.
Production Insight
HAVING is evaluated after all the grouping and aggregation work is done — it cannot short-circuit the computation.
For large datasets, use WHERE to pre-filter rows before GROUP BY whenever possible — this reduces the rows being aggregated.
HAVING MIN(total) > 0 is legitimate; WHERE MIN(total) > 0 throws an error.
Key Takeaway
HAVING filters groups, not rows — it runs after GROUP BY and can use aggregate results.
Pre-filter with WHERE before GROUP BY wherever possible — it reduces work before aggregation begins.
HAVING and WHERE can coexist: WHERE filters rows first, HAVING filters groups after.

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.

real_world_groupby_patterns.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
-- ── PATTERN 1: Top-N customers by total spend ───────────────────
-- Goal: find the 5 highest-spending customers in 2024

SELECT
    customer_id,
    COUNT(order_id)  AS total_orders,
    SUM(amount)      AS lifetime_spend
FROM orders
WHERE order_date >= '2024-01-01'   -- narrow to 2024 first (WHERE = pre-filter)
GROUP BY customer_id
HAVING SUM(amount) > 0             -- exclude any customer with $0 net (edge case)
ORDER BY lifetime_spend DESC
LIMIT 5;                           -- take only the top 5 after sorting


-- ── PATTERN 2: Duplicate detection ──────────────────────────────
-- Goal: find any email addresses that appear more than once in the
-- customers table — a sign of duplicate registrations

SELECT
    email,
    COUNT(*) AS registration_count   -- count how many rows share this email
FROM customers
GROUP BY email
HAVING COUNT(*) > 1                  -- HAVING filters to only the duplicates
ORDER BY registration_count DESC;    -- worst offenders first


-- ── PATTERN 3: Engagement cohort — users who placed 3+ orders ───
-- Goal: identify loyal repeat customers for a marketing campaign

SELECT
    customer_id,
    COUNT(order_id)      AS order_count,
    MIN(order_date)      AS first_order_date,  -- when they became a customer
    MAX(order_date)      AS latest_order_date, -- most recent activity
    SUM(amount)          AS total_spend
FROM orders
GROUP BY customer_id
HAVING COUNT(order_id) >= 3          -- only customers with 3 or more orders
ORDER BY total_spend DESC;
Output
-- Pattern 1 result (top 5 customers, 2024):
customer_id | total_orders | lifetime_spend
------------+--------------+---------------
1042 | 17 | 4830.00
891 | 12 | 4215.50
2201 | 9 | 3990.00
334 | 22 | 3740.25
1788 | 8 | 3600.00
-- Pattern 2 result (duplicate emails):
email | registration_count
--------------------------+-------------------
john.doe@example.com | 3
test.user@example.com | 2
-- Pattern 3 result (loyal customers, 3+ orders):
customer_id | order_count | first_order_date | latest_order_date | total_spend
------------+-------------+------------------+-------------------+------------
1042 | 17 | 2022-03-14 | 2024-11-01 | 12840.00
334 | 22 | 2021-07-22 | 2024-10-28 | 9310.50
891 | 12 | 2023-01-05 | 2024-09-15 | 8450.00
Interview Gold: The Duplicate Detection Query
The Pattern 2 duplicate-detection query is one of the most commonly asked SQL problems in interviews. Memorise this shape: SELECT column, COUNT() FROM table GROUP BY column HAVING COUNT() > 1. It's also the foundation of data deduplication work in real data pipelines — interviewers ask it precisely because it's genuinely useful in production.
Production Insight
The ROLLUP modifier (GROUP BY category, subcategory WITH ROLLUP) produces subtotals and grand totals automatically — useful for hierarchical reports without multiple queries.
GROUP BY GROUPING SETS allows computing multiple groupings in a single pass — more efficient than UNION ALL of separate queries.
Date truncation in GROUP BY: DATE_TRUNC('month', created_at) groups by month; EXTRACT(YEAR FROM created_at) groups by year.
Key Takeaway
Real-world GROUP BY combines multiple dimensions: category + month + region are routine.
ROLLUP and GROUPING SETS produce multi-level summaries in one query — avoid N separate GROUP BY queries.
Always ORDER BY the GROUP BY column for deterministic, human-readable report output.

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.

groupby_mistakes_and_fixes.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
-- ── MISTAKE 1: Using WHERE on an aggregate ───────────────────────
-- WRONG — this throws: "aggregate functions are not allowed in WHERE"
SELECT category, SUM(amount) AS total_revenue
FROM orders
WHERE SUM(amount) > 50000   -- ❌ WHERE runs before aggregation — SUM doesn't exist yet
GROUP BY category;

-- FIXED — move the aggregate condition to HAVING
SELECT category, SUM(amount) AS total_revenue
FROM orders
GROUP BY category
HAVING SUM(amount) > 50000;  -- ✅ HAVING runs after aggregation — SUM exists here


-- ── MISTAKE 2: Referencing a SELECT alias in HAVING ──────────────
-- WRONG — most databases can't resolve the alias 'total_revenue' in HAVING
-- (PostgreSQL and SQL Server will error; MySQL may accept it as a non-standard extension)
SELECT
    category,
    SUM(amount) AS total_revenue
FROM orders
GROUP BY category
HAVING total_revenue > 50000;  -- ❌ alias not guaranteed to be visible in HAVING

-- FIXED — repeat the full aggregate expression in HAVING
SELECT
    category,
    SUM(amount) AS total_revenue
FROM orders
GROUP BY category
HAVING SUM(amount) > 50000;   -- ✅ reference the expression, not the alias


-- ── MISTAKE 3: Forgetting that COUNT(*) includes NULLs ───────────
-- COUNT(*) counts all rows in the group, including rows with NULL values.
-- COUNT(column_name) counts only non-NULL values in that column.
-- These can give different numbers — and the difference matters.

SELECT
    category,
    COUNT(*)          AS all_rows,        -- counts every row, NULLs included
    COUNT(amount)     AS non_null_amounts -- counts only rows where amount IS NOT NULL
FROM orders
GROUP BY category;

-- If your 'amount' column has NULLs, these two columns will differ.
-- Use COUNT(column_name) when you specifically want to exclude NULLs from your count.
Output
-- Mistake 1 (wrong version): ERROR
-- ERROR: aggregate functions are not allowed in WHERE
-- Mistake 1 (fixed): OK
category | total_revenue
------------+--------------
Electronics | 84350.00
Furniture | 61200.00
-- Mistake 2 (wrong version): ERROR in PostgreSQL/SQL Server
-- ERROR: column 'total_revenue' does not exist
-- (MySQL may succeed due to its alias-in-HAVING extension — dangerous portability trap)
-- Mistake 3 output:
category | all_rows | non_null_amounts
------------+----------+-----------------
Electronics | 142 | 138 -- 4 orders had NULL amount
Furniture | 89 | 89 -- no NULLs here
Clothing | 310 | 305 -- 5 NULLs
Watch Out: MySQL's Alias-in-HAVING Is Non-Standard
MySQL accepts HAVING alias_name > value as a convenience extension to standard SQL. If you write queries that way and then migrate to PostgreSQL or SQL Server, they'll break. Stick to repeating the full aggregate expression in HAVING — it works on every major database engine and makes the query explicit about what it's filtering on.
Production Insight
COUNT(column) vs COUNT(*) inside GROUP BY has the same NULL-skipping difference as in ungrouped aggregates.
Filtering with WHERE before GROUP BY is always faster than filtering groups with HAVING — reduce the dataset first.
For the 'top N per group' pattern (top 3 customers per country), use window functions instead of GROUP BY.
Key Takeaway
WHERE pre-filters rows; HAVING post-filters groups — use WHERE whenever the condition doesn't involve aggregates.
COUNT(column) skips NULLs inside groups just as it does globally — use COUNT(*) for row counts.
For top-N per group, window functions (ROW_NUMBER, RANK) are cleaner than GROUP BY tricks.

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.

WindowVsGroupBy_Having.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — database tutorial

-- Window function: keeps rows, filters on aggregate
SELECT
    order_id,
    customer_id,
    order_amount,
    SUM(order_amount) OVER (PARTITION BY customer_id) AS customer_lifetime_value
FROM orders
QUALIFY SUM(order_amount) OVER (PARTITION BY customer_id) > 10000
ORDER BY customer_id;

-- GROUP BY + HAVING equivalent: loses all row-level details
SELECT
    customer_id,
    SUM(order_amount) AS customer_lifetime_value
FROM orders
GROUP BY customer_id
HAVING SUM(order_amount) > 10000;
Output
-- Window query output (example):
order_id | customer_id | order_amount | customer_lifetime_value
1001 | 42 | 2500.00 | 15000.00
1002 | 42 | 3500.00 | 15000.00
...
-- GROUP BY + HAVING output:
customer_id | customer_lifetime_value
42 | 15000.00
Production Trap: HAVING Erases Row Context
If your downstream process needs individual records (debugging, UI drill-down, incremental loads), GROUP BY + HAVING will silently drop them. Use window functions with QUALIFY (BigQuery, Snowflake, Teradata) or a subquery with WHERE to preserve rows and filter on aggregate logic.
Key Takeaway
When you need group-level filters AND row-level detail, window functions beat GROUP BY + HAVING every time.

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.

HavingAliasExecutionOrder.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// io.thecodeforge — database tutorial

-- BAD: alias not visible in HAVING
SELECT
    customer_id,
    SUM(amount) AS total_revenue
FROM orders
GROUP BY customer_id
HAVING total_revenue > 5000;  -- ERROR: column 'total_revenue' does not exist

-- GOOD: repeat aggregate expression
SELECT
    customer_id,
    SUM(amount) AS total_revenue
FROM orders
GROUP BY customer_id
HAVING SUM(amount) > 5000;

-- CLEANER: CTE for readability
WITH customer_revenue AS (
    SELECT
        customer_id,
        SUM(amount) AS total_revenue
    FROM orders
    GROUP BY customer_id
)
SELECT *
FROM customer_revenue
WHERE total_revenue > 5000;
Output
-- First query produces error, no output
-- Second and third queries produce:
customer_id | total_revenue
42 | 15000.00
17 | 12000.50
Senior Shortcut: Use CTEs to Bypass the HAVING Alias Limitation
When HAVING becomes unreadable with long aggregate expressions, move it to a CTE's WHERE clause. You get alias visibility, cleaner code, and better reuse for debugging subqueries. It's not slower — modern optimisers flatten CTEs.
Key Takeaway
HAVING evaluates before SELECT, so aliases are invisible. Duplicate the aggregate or use a CTE.

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.

DataQualityCheck_Having.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// io.thecodeforge — database tutorial

-- Detect duplicate order IDs (should never happen)
SELECT
    order_id,
    COUNT(*) AS row_count
FROM orders
GROUP BY order_id
HAVING COUNT(*) > 1;

-- Detect orders where all timestamps are identical (stale batch load)
SELECT
    batch_id,
    MIN(loaded_at) AS first_loaded,
    MAX(loaded_at) AS last_loaded,
    COUNT(*) AS rows
FROM orders
GROUP BY batch_id
HAVING MIN(loaded_at) = MAX(loaded_at);

-- Find customers with multiple active statuses (data corruption)
SELECT
    customer_id,
    COUNT(DISTINCT status) AS status_variants
FROM customers
GROUP BY customer_id
HAVING COUNT(DISTINCT status) > 1;
Output
-- Duplicate detection output:
order_id | row_count
1001 | 3
-- Stale batch detection:
batch_id | first_loaded | last_loaded | rows
7 | 2024-03-15 10:00:00 | 2024-03-15 10:00:00 | 500
-- Corruption detection:
customer_id | status_variants
88 | 2
Never Do This: Trust Raw Data Without a HAVING Health Check
Add a simple HAVING-based quality gate at the start of every ETL job. 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.
Key Takeaway
HAVING isn't just for business filters — it's your cheapest, fastest data quality scanner. Use it to catch duplicates, stale loads, and corruption on ingestion.

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.

CountStarVsColumn.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — database tutorial

SELECT
    customer_id,
    COUNT(*) AS total_rows,
    COUNT(order_id) AS orders_with_id,
    COUNT(DISTINCT order_id) AS unique_orders
FROM orders
GROUP BY customer_id
HAVING COUNT(*) > COUNT(order_id);

-- Output shows customers with NULL order_ids
Output
customer_id | total_rows | orders_with_id | unique_orders
1001 | 5 | 4 | 3
2047 | 12 | 11 | 9
Production Trap:
Mixing COUNT(*) and COUNT(column) in HAVING without understanding NULL behavior causes silent data loss. Audit your HAVING clauses — if you filter on COUNT(column) expecting all rows, you're wrong.
Key Takeaway
COUNT(*) counts rows; COUNT(column) counts non-NULLs. Never treat them as equal in GROUP BY + HAVING logic.

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.

Example.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// io.thecodeforge — database tutorial

-- Example 1: Sales quantity per product
SELECT product_id, SUM(quantity) AS total_sold
FROM sales
WHERE status = 'completed'
GROUP BY product_id
HAVING SUM(quantity) > 100;

-- Output:
product_id | total_sold
P100       | 450
P203       | 312

-- Example 2: Countries with revenue > $2000
SELECT country, SUM(amount) AS total_revenue
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY country
HAVING SUM(amount) > 2000;

-- Output:
country    | total_revenue
US         | 12500.00
DE         | 3400.50
Output
P100 | 450
P203 | 312
US | 12500.00
DE | 3400.50
Senior Shortcut:
Put filters that don't involve aggregates in WHERE, not HAVING. WHERE filters rows before GROUP BY — less data to aggregate, faster query. HAVING is only for post-aggregation conditions.
Key Takeaway
WHERE filters rows before aggregation; HAVING filters groups after. Use both correctly and your queries stay fast and correct.
● Production incidentPOST-MORTEMseverity: high

Monthly Revenue Report Inflated by 40% Due to Missing GROUP BY Column

Symptom
Monthly product revenue totals were consistently ~40% higher than totals computed by the finance team from raw exports. No errors, no warnings.
Assumption
The developer assumed GROUP BY category was sufficient when the query also selected subcategory without aggregating it. MySQL's lenient mode silently picked an arbitrary subcategory value for each group.
Root cause
SELECT category, subcategory, SUM(revenue) ... GROUP BY category — MySQL with ONLY_FULL_GROUP_BY disabled picked an arbitrary subcategory value per group instead of raising an error. This caused subcategory-level revenue to be attributed to the wrong groups, inflating some and deflating others.
Fix
Added subcategory to GROUP BY: GROUP BY category, subcategory. Enabled ONLY_FULL_GROUP_BY mode in MySQL config so future missing GROUP BY columns throw an error rather than silently producing wrong results.
Key lesson
  • 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
Production debug guideDiagnosing wrong aggregations and filter errors3 entries
Symptom · 01
Error: aggregate functions not allowed in WHERE
Fix
Move aggregate conditions to HAVING: WHERE SUM(amount) > 1000 → HAVING SUM(amount) > 1000. WHERE runs before GROUP BY — aggregate values don't exist yet when WHERE is evaluated.
Symptom · 02
GROUP BY returns different counts than expected
Fix
Check for NULLs in the grouping column — NULL forms its own group. Run SELECT grouping_col, COUNT(*) ... GROUP BY grouping_col to see NULL as a separate group. Decide whether to COALESCE(grouping_col, 'Unknown') or filter with WHERE grouping_col IS NOT NULL.
Symptom · 03
MySQL returns results with non-grouped column without error
Fix
ONLY_FULL_GROUP_BY mode is disabled. Enable it: SET GLOBAL sql_mode = (SELECT CONCAT(@@sql_mode, ',ONLY_FULL_GROUP_BY')). Without it, MySQL picks arbitrary values for non-grouped columns — this produces silently wrong results.
AspectWHEREHAVING
When it runsBefore GROUP BY — filters raw rowsAfter GROUP BY — filters aggregated groups
What it can filter onAny column value in the raw tableAggregate results (SUM, COUNT, AVG, etc.) or grouped columns
Can use aggregate functions?No — causes a syntax errorYes — that's its entire purpose
Performance impactReduces rows fed into grouping — fasterRuns after aggregation — no early row reduction benefit
Use case exampleWHERE order_date >= '2024-01-01'HAVING SUM(amount) > 50000
Works without GROUP BY?Yes — filters all rows normallyYes, but treats entire table as one group (rare / edge case)
Can they coexist in one query?Yes — WHERE fires first, then GROUP BY, then HAVINGYes — the typical production pattern uses both together

Key takeaways

1
GROUP BY collapses many rows into one summary row per unique group value
after that point, every SELECT column must be the group key or an aggregate function, with no exceptions.
2
SQL execution order is FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY
WHERE never sees aggregate results; HAVING never sees raw pre-grouped rows. Knowing this order makes every GROUP BY / HAVING decision obvious.
3
HAVING and WHERE can and should coexist
use WHERE to strip irrelevant raw rows before grouping (cheaper), then use HAVING to filter the resulting groups by their aggregate values (necessary for conditions WHERE can't reach).
4
The duplicate-detection pattern
SELECT col, COUNT() FROM table GROUP BY col HAVING COUNT() > 1 — is one of the most practically useful SQL patterns in production data work and one of the most commonly asked interview queries.

Common mistakes to avoid

3 patterns
×

Putting an aggregate function in WHERE

Symptom
Error: aggregate functions are not allowed in WHERE — WHERE SUM(amount) > 1000 throws an error immediately
Fix
Use HAVING for aggregate conditions: GROUP BY category HAVING SUM(amount) > 1000. WHERE runs before grouping — aggregate values don't exist yet. Both WHERE and HAVING can appear together: WHERE filters rows first, HAVING filters groups after.
×

Selecting a non-grouped, non-aggregated column

Symptom
Error in PostgreSQL/SQL Server: column must appear in GROUP BY. Silent wrong results in MySQL with ONLY_FULL_GROUP_BY disabled — MySQL picks an arbitrary value for the ungrouped column.
Fix
Every column in SELECT must be in GROUP BY or wrapped in an aggregate function. Enable ONLY_FULL_GROUP_BY in MySQL to catch this automatically. Add the column to GROUP BY if you want per-column breakdowns, or remove it from SELECT if you don't need it.
×

Using COUNT(*) when COUNT(specific_column) is intended

Symptom
Group counts appear higher than expected — NULLs in the counted column are included in COUNT(*) but would be excluded from COUNT(column_name)
Fix
COUNT(*) counts every row in the group including NULLs. COUNT(column) counts only non-NULL rows. Use COUNT(DISTINCT column) to count unique non-NULL values per group. Run both side by side when debugging count discrepancies.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between WHERE and HAVING, and can you use them to...
Q02JUNIOR
Write a query to find email addresses that appear more than once in a us...
Q03SENIOR
If you write HAVING category = 'Electronics' instead of WHERE category =...
Q01 of 03JUNIOR

What is the difference between WHERE and HAVING, and can you use them together?

ANSWER
WHERE filters individual rows before GROUP BY runs — it cannot use aggregate functions because aggregation hasn't happened yet. HAVING filters groups after GROUP BY and aggregation — it can use aggregate conditions like HAVING COUNT(*) > 5. Both can appear in the same query: WHERE runs first to reduce the rows being grouped, then GROUP BY aggregates the filtered rows, then HAVING removes groups that don't meet the aggregate condition. Example: SELECT dept, AVG(salary) FROM employees WHERE hire_year > 2020 GROUP BY dept HAVING AVG(salary) > 70000 — WHERE reduces to post-2020 employees, GROUP BY aggregates by department, HAVING keeps only high-paying departments.
FAQ · 3 QUESTIONS

Frequently Asked Questions

01
Can I use HAVING without GROUP BY in SQL?
02
Why can't I use a column alias defined in SELECT inside my HAVING clause?
03
What's the performance difference between filtering with WHERE versus HAVING?
N
Naren Founder & Principal Engineer

20+ years shipping high-throughput database systems. Drawn from code that ran under real load.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's SQL Basics. Mark it forged?

9 min read · try the examples if you haven't

Previous
SQL JOINs Explained
8 / 16 · SQL Basics
Next
SQL Aggregate Functions