The logical order HAVING has to obey

SQL evaluates a query in a fixed logical order that's different from the order you type it in: FROMWHEREGROUP BYHAVINGSELECTORDER BY. Each stage consumes the output of the one before it. By the time HAVING runs, GROUP BY has already collapsed the surviving rows into groups and computed any aggregate functions — which is exactly the input HAVING needs to do its job. The full breakdown of every stage is in SQL Execution Order Explained.

  • WHEREFilters raw rows. No aggregates exist yet — none have been computed.
  • GROUP BYCollapses the surviving rows into groups and computes aggregate values per group.
  • HAVINGFilters those groups, using the aggregate values GROUP BY just produced.
  • The one-line reason: HAVING can't run before GROUP BY because HAVING's job is to check aggregate values, and those values are literally computed by GROUP BY. There's nothing for HAVING to filter until grouping has happened.

    What HAVING actually filters

    HAVING operates on the result of GROUP BY — one row per group, with any aggregate columns already calculated. It cannot see individual pre-grouped rows at all; by the time it runs, those rows have already been folded into their groups.

    having-basic.sql
    SELECT customer_id, COUNT(*) AS order_count
    FROM orders
    GROUP BY customer_id
    HAVING COUNT(*) > 5;

    This returns only the customers whose order count — a value that only exists after grouping — is greater than 5. There is no way to express this condition in WHERE, because WHERE never sees a completed count; it only sees one row at a time, before any grouping has occurred.

    WHERE vs HAVING, side by side

    The cleanest way to see the difference is a query that legitimately needs both — one condition on raw rows, one on the aggregated result. For the full three-way comparison including SELECT, see SELECT vs WHERE vs HAVING.

    where-and-having.sql
    SELECT customer_id, SUM(total) AS lifetime_value
    FROM orders
    WHERE status = 'completed'          -- row filter, runs first
    GROUP BY customer_id
    HAVING SUM(total) > 1000          -- group filter, runs after
    ORDER BY lifetime_value DESC;
    WHEREHAVING
    FiltersIndividual rowsGroups (after GROUP BY)
    Can reference aggregates?NoYes — that's its purpose
    RunsBefore GROUP BYAfter GROUP BY
    Works without GROUP BY?Yes — alwaysYes — treats the whole table as one group

    The common mistake: row filters in HAVING

    Because HAVING looks like a second WHERE, it's tempting to put every condition there — including ones that don't need an aggregate at all. This is a recognized anti-pattern covered in GROUP BY Mistakes That Break SQL Queries.

    row-filter-in-having.sql
    -- Works, but wasteful: groups every row, including
    -- rows HAVING is about to throw away anyway
    SELECT customer_id, COUNT(*)
    FROM orders
    GROUP BY customer_id
    HAVING customer_id IS NOT NULL;
    
    -- Better: eliminate the row before grouping, not after
    SELECT customer_id, COUNT(*)
    FROM orders
    WHERE customer_id IS NOT NULL
    GROUP BY customer_id;

    Can HAVING be used without GROUP BY?

    Yes — a query with no GROUP BY is logically treated as one single group containing every surviving row, so HAVING still has a group to filter, just one instead of many.

    having-no-group-by.sql
    SELECT COUNT(*) AS total_orders
    FROM orders
    HAVING COUNT(*) > 100;
    -- Returns one row if there are more than 100 orders total,
    -- otherwise returns nothing at all

    Why WHERE-then-HAVING is usually faster

    WHERE runs before grouping, so rows it eliminates never get counted, summed, or grouped at all. A condition placed in HAVING instead has to wait until every row has already been grouped and aggregated, only to throw part of that work away afterward. Pushing row-level conditions into WHERE whenever possible is one of the most reliable wins in the SQL Optimization Checklist.

    Key takeaways

    • HAVING comes after GROUP BY because it filters on aggregate values that GROUP BY produces.
    • WHERE filters rows before grouping; HAVING filters groups after grouping.
    • HAVING works even without GROUP BY — the whole result set is treated as one group.
    • A row-level condition belongs in WHERE, not HAVING, even though HAVING would technically work.
    • Filtering early with WHERE reduces how much data GROUP BY has to aggregate in the first place.