VillageSQL is a drop-in replacement for MySQL with extensions.
All examples on this page work on VillageSQL. Install Now →
GROUP BY that returns in milliseconds on a test table can take minutes on a
real one, and the query text is identical. The difference is whether the server
can walk an index in group order or has to build a temporary table first. This
guide is about that difference, and about the choices that decide which you
get.
If you are learning the clauses themselves, the tutorial teaches them against a
sample database: aggregate functions,
GROUP BY, HAVING,
COUNT DISTINCT,
GROUP_CONCAT and WITH ROLLUP.
Every query and plan on this page came from 1 server against the Sakila sample
database. Costs and row estimates come from InnoDB’s sampled statistics and from
your optimizer_switch settings, so your numbers will differ. The shape of each
plan is the part that transfers.
Grouping without an index builds a temporary table
To group rows, the server has to bring the rows of each group together. With no index on the grouping column it does that by building a temporary table:Using temporary is the line to look for. On 1,000 rows it costs nothing. The
table is held in memory while it fits, and spills to disk when it does not,
which is where a grouped report stops being fast.
Which setting caps that memory depends on the engine the server uses for
internal temporary tables. Check yours before you change anything:
TempTable.
temptable_max_ram and temptable_max_mmap cap what the engine may hold across
all sessions. tmp_table_size caps each individual internal temporary table: at
1 KB a grouped query over 1,000 rows took Created_tmp_disk_tables from 0 to 1,
and at 64 MB the same query stayed in memory. Watch that counter rather than
guessing:
Using temporary and no Using filesort. The server reads the index in
order, counts each run of equal values, and emits a row. Using index means it
never touched the table itself.
So the first thing to check on a slow GROUP BY is whether an index leads with
the grouping columns, in the same order you grouped them. A composite index on
(a, b) serves GROUP BY a and GROUP BY a, b. For GROUP BY b the server
can still scan it as a covering index, reported as type: index with
Using index, but the rows arrive out of group order so Using temporary comes
back with them.
WHERE and HAVING are not a style choice
Both can express a filter. They cost differently, becauseWHERE runs before
the grouping and HAVING runs after it.
A condition on the grouping column itself can be written either way and means
the same thing, which makes it the honest comparison. In WHERE, the server
narrows the index range first and groups only what it read:
HAVING, the same restriction arrives too late to narrow anything:
WHERE version reads an
estimated 2,710 index entries; the HAVING version reads the whole index and
throws most of the groups away afterwards.
Those estimates come from InnoDB’s sampled statistics and from the cost
constants in mysql.server_cost, so your numbers will differ from these. The
shape of the 2 plans is the part that transfers.
The rule: put a condition in WHERE whenever it tests a row. Use HAVING only
for what tests a group, which in practice means anything containing an
aggregate. A HAVING clause with no aggregate in it is nearly always a WHERE
clause in the wrong place, and WHERE with an aggregate in it is
ERROR 1111 (HY000): Invalid use of group function.
ONLY_FULL_GROUP_BY, and the escape hatch
only_full_group_by is on by default and rejects a query that selects a column
which is neither grouped nor aggregated, with ERROR 1055. The reason is that
such a query has no defined answer: the group holds many rows and nothing says
which one to show.
ANY_VALUE() tells the server you accept an arbitrary one, and the query then
runs:
ANY_VALUE() when the column is functionally dependent on the grouping
column and the server cannot prove it, which is the legitimate case. Do not use
it to silence the error on a column that genuinely varies within the group: the
value you get is not the first, the smallest, or anything else you can rely on,
and it can change when the plan changes. MIN() or MAX() says what you meant
and costs the same.
Turning only_full_group_by off globally converts every one of these errors
into a wrong answer nobody sees. It is the wrong fix.
The check sees through an expression built on a grouped column, so
SELECT COALESCE(rating, 'unrated') ... GROUP BY rating is accepted. It does
not see through an expression built on a grouped expression. Group by
DATE_FORMAT(payment_date, '%Y-%m') and select
COALESCE(DATE_FORMAT(payment_date, '%Y-%m'), 'ALL'), and the same ERROR 1055
names payment_date, because the server compares the 2 expressions rather than
the columns inside them. That is the common reporting case, and the way out is
to group in a derived table and wrap the result outside it:
Aggregating a one-to-many join
Joining before grouping multiplies rows, so aSUM over the result double
counts. The symptom is a total that is too high by a factor nobody can explain.
Aggregate each side in its own subquery and join the results, rather than
joining first and grouping afterwards:
When you do not want the rows collapsed
GROUP BY replaces the rows with 1 row per group. When you want the aggregate
beside each row, a window function does that without collapsing anything, and
usually without the temporary table:
Troubleshooting
See also
- Window functions in MySQL 8.4 — aggregating without collapsing the rows
- MySQL JOIN performance and common mistakes — why a one-to-many join inflates a total
- When a MySQL CTE beats a derived table — pre-aggregating before the main query
- Common MySQL errors — ERROR 1055 and ERROR 1111 in context
- GROUP BY — the tutorial, if you want the clause itself first

