GROUP BY ist mehr als nur Gruppieren. Wer die Semantik, ONLY_FULL_GROUP_BY, den Unterschied zwischen WHERE und HAVING sowie die Fan-out-Falle nicht kennt, baut Queries die falsche Zahlen liefern.
Inhaltsverzeichnis
- 1. GROUP BY Semantik: Was die Regel wirklich bedeutet
- 2. ONLY_FULL_GROUP_BY: Was Magento erzwingt
- 3. WHERE vs HAVING: Reihenfolge und Indexnutzung
- 4. GROUP BY mit mehreren Spalten
- 5. WITH ROLLUP: Zwischensummen und Gesamttotals
- 6. Aggregatfunktionen: SUM, COUNT, AVG, GROUP_CONCAT
- 7. Die Fan-out-Falle mit JOINs
- 8. Magento-Praxisbeispiele
- 9. Unterstützung
- 10. Zusammenfassung
- 11. FAQ
1. GROUP BY Semantik: Was die Regel wirklich bedeutet
Die fundamentale Regel von GROUP BY: Jede Spalte, die im SELECT erscheint und keine Aggregatfunktion ist, muss auch in der GROUP BY-Klausel stehen — oder muss funktional vom Primärschlüssel einer gruppierten Tabelle abhängen. Das klingt formal, hat aber einen einleuchtenden Grund.
Wenn nach store_id gruppiert wird, gibt es pro store_id genau eine Ausgabezeile. Alle Spalten in dieser Ausgabezeile müssen eindeutig bestimmbar sein. Eine Aggregatfunktion wie SUM(grand_total) ist eindeutig — sie fasst alle Werte der Gruppe zusammen. Eine Spalte wie increment_id ist nicht eindeutig — welcher Wert der vielen Bestellungen soll genommen werden? Genau das verhindert ONLY_FULL_GROUP_BY.
2. ONLY_FULL_GROUP_BY: Was Magento erzwingt
Magento setzt den SQL-Mode ONLY_FULL_GROUP_BY voraus und konfiguriert ihn aktiv. Das ist eine gute Praxis, weil MySQL ohne diesen Mode bei nicht-deterministischen Gruppenabfragen einfach einen zufälligen Wert aus der Gruppe zurückgibt — was zu schwer aufspürbaren, falschen Daten führt.
-- Check current sql_mode (Magento requires ONLY_FULL_GROUP_BY)
SELECT @@sql_mode;
-- Expected output includes: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,...
-- This query FAILS with ONLY_FULL_GROUP_BY:
-- increment_id is not in GROUP BY and not an aggregate function
SELECT store_id, increment_id, SUM(grand_total)
FROM sales_order
GROUP BY store_id;
-- ERROR: 'magento.sales_order.increment_id' is not in GROUP BY
-- Correct: only aggregate or group-key columns in SELECT
SELECT
store_id,
COUNT(*) AS order_count,
SUM(grand_total) AS total_revenue,
AVG(grand_total) AS avg_order
FROM sales_order
WHERE status = 'complete'
GROUP BY store_id
ORDER BY total_revenue DESC;
Der häufigste Fehler: Entwickler fügen im SELECT eine Spalte hinzu, die "eigentlich gleich für alle Zeilen in der Gruppe" ist — zum Beispiel store_name wenn nach store_id gruppiert wird. In dem Fall muss store_name trotzdem in die GROUP BY-Klausel oder über einen JOIN aus einer Dimensionstabelle geholt werden.
3. WHERE vs HAVING: Reihenfolge und Indexnutzung
Das ist einer der wichtigsten praktischen Unterschiede in SQL: WHERE filtert vor der Gruppierung, HAVING filtert nach der Gruppierung. Das hat erhebliche Konsequenzen für Performance und Korrektheit.
WHERE kann Indexes nutzen, weil er auf den Rohdaten operiert bevor Aggregate berechnet werden. HAVING kann nur filtern nachdem die gesamte Gruppe berechnet wurde — ein Index auf der gruppierten Spalte hilft hier nicht mehr.
-- WHERE filters BEFORE grouping — uses index on created_at, fast
SELECT
customer_email,
COUNT(*) AS order_count,
SUM(grand_total) AS lifetime_value
FROM sales_order
WHERE created_at >= '2025-01-01' -- filters rows BEFORE aggregation
AND status = 'complete'
GROUP BY customer_email
ORDER BY lifetime_value DESC
LIMIT 50;
-- HAVING filters AFTER grouping — computed on aggregated result, no index benefit
SELECT
customer_email,
COUNT(*) AS order_count,
SUM(grand_total) AS lifetime_value
FROM sales_order
WHERE status = 'complete'
GROUP BY customer_email
HAVING order_count >= 3 -- filters AFTER aggregation
AND lifetime_value >= 500.00 -- HAVING can reference aliases from SELECT
ORDER BY lifetime_value DESC;
-- Wrong pattern: using HAVING for what WHERE could do
-- This forces a full table scan + group operation before filtering
SELECT store_id, SUM(grand_total)
FROM sales_order
GROUP BY store_id
HAVING store_id = 1; -- BAD: should be WHERE store_id = 1
-- Correct: filter with WHERE first, group only what's needed
SELECT store_id, SUM(grand_total)
FROM sales_order
WHERE store_id = 1 -- GOOD: filtered before grouping
GROUP BY store_id;
4. GROUP BY mit mehreren Spalten
GROUP BY kann nach beliebig vielen Spalten gruppieren. Das Ergebnis ist dann eine Zeile pro eindeutiger Kombination der gruppierten Spalten. Eine klassische Anforderung: Umsatz pro Kunde pro Monat.
-- Revenue per customer per month — multi-column GROUP BY
SELECT
customer_email,
YEAR(created_at) AS year,
MONTH(created_at) AS month,
COUNT(*) AS orders,
SUM(grand_total) AS revenue,
AVG(grand_total) AS avg_order_value,
GROUP_CONCAT(increment_id
ORDER BY created_at
SEPARATOR ', ') AS order_ids
FROM sales_order
WHERE status NOT IN ('canceled', 'pending')
AND created_at >= '2025-01-01'
GROUP BY customer_email, YEAR(created_at), MONTH(created_at)
ORDER BY customer_email, year DESC, month DESC;
5. WITH ROLLUP: Zwischensummen und Gesamttotals
Das Modifier WITH ROLLUP erweitert das Ergebnis um automatisch berechnete Subtotals und ein Gesamttotal. Pro Gruppenebene wird eine zusätzliche Aggregationszeile eingefügt, die durch NULL in den Gruppierungsspalten erkennbar ist.
-- Sales totals per store with subtotals using WITH ROLLUP
SELECT
COALESCE(store_id, 'ALL STORES') AS store,
COALESCE(status, 'ALL STATUSES') AS status,
COUNT(*) AS order_count,
SUM(grand_total) AS revenue
FROM sales_order
WHERE created_at >= '2025-01-01'
GROUP BY store_id, status WITH ROLLUP
ORDER BY store_id, status;
/*
Result:
store | status | order_count | revenue
1 | complete | 142 | 28.450,00
1 | pending | 23 | 4.120,00
1 | NULL | 165 | 32.570,00 ← subtotal for store 1
2 | complete | 89 | 15.220,00
2 | NULL | 89 | 15.220,00 ← subtotal for store 2
NULL | NULL | 254 | 47.790,00 ← grand total
*/
6. Aggregatfunktionen: SUM, COUNT, AVG, GROUP_CONCAT
Der kritische Unterschied zwischen COUNT(*) und COUNT(column): COUNT(*) zählt alle Zeilen der Gruppe einschließlich NULL-Werten. COUNT(column) zählt nur Zeilen, wo column nicht NULL ist. Für verlässliche Statistiken ist es wichtig, den richtigen zu wählen.
-- Difference between COUNT(*) and COUNT(col)
SELECT
store_id,
COUNT(*) AS total_rows, -- all rows
COUNT(customer_id) AS rows_with_customer,-- non-NULL only
COUNT(DISTINCT customer_email) AS unique_customers,
SUM(grand_total) AS revenue,
AVG(grand_total) AS avg_order,
MIN(grand_total) AS min_order,
MAX(grand_total) AS max_order,
-- GROUP_CONCAT: aggregate string values
GROUP_CONCAT(
DISTINCT coupon_code
ORDER BY coupon_code
SEPARATOR ' | '
) AS coupons_used
FROM sales_order
WHERE created_at >= '2025-01-01'
AND status = 'complete'
GROUP BY store_id;
7. Die Fan-out-Falle mit JOINs
Die Fan-out-Falle ist ein häufiger und gefährlicher Fehler: Wenn eine Tabelle mit einer 1:N-Beziehung gejoint wird und danach eine Aggregation wie SUM() ausgeführt wird, werden die Werte der Haupttabelle mehrfach gezählt — für jede Zeile der Detailtabelle einmal.
-- Fan-out trap: SUM(grand_total) gets inflated by the JOIN
-- Each order appears N times (once per order item), so grand_total is summed N times
SELECT
so.customer_email,
SUM(so.grand_total) AS WRONG_revenue, -- inflated!
SUM(soi.row_total) AS correct_items_revenue
FROM sales_order so
JOIN sales_order_item soi ON soi.order_id = so.entity_id
GROUP BY so.customer_email;
-- Fix option 1: use SUM(DISTINCT) — only works for truly unique values
SELECT
customer_email,
SUM(DISTINCT grand_total) AS revenue -- risky if two orders have same total!
FROM sales_order so
JOIN sales_order_item soi ON soi.order_id = so.entity_id
GROUP BY customer_email;
-- Fix option 2 (best practice): aggregate in a subquery before joining
SELECT
so.customer_email,
so_totals.revenue,
soi_totals.items_sold
FROM (
SELECT customer_email, SUM(grand_total) AS revenue
FROM sales_order
WHERE status = 'complete'
GROUP BY customer_email
) so_totals
JOIN (
SELECT so.customer_email, SUM(soi.qty_ordered) AS items_sold
FROM sales_order so
JOIN sales_order_item soi ON soi.order_id = so.entity_id
GROUP BY so.customer_email
) soi_totals USING (customer_email)
ORDER BY revenue DESC;
8. Magento-Praxisbeispiele
Im Magento-Kontext tauchen GROUP BY und HAVING in vielen typischen Reporting-Anforderungen auf. Die folgenden Beispiele sind direkt auf Magento-Tabellen ausgerichtet und praxistauglich.
-- Orders per customer with revenue (Magento sales_order)
SELECT
customer_email,
customer_firstname,
customer_lastname,
COUNT(DISTINCT entity_id) AS order_count,
SUM(grand_total) AS lifetime_value,
MAX(created_at) AS last_order_date
FROM sales_order
WHERE status IN ('complete', 'processing')
GROUP BY customer_email, customer_firstname, customer_lastname
HAVING order_count >= 2
ORDER BY lifetime_value DESC
LIMIT 100;
-- Revenue per category (simplified, without EAV join)
-- Works on denormalized reporting table if available
SELECT
soi.sku,
COUNT(DISTINCT soi.order_id) AS orders_containing_sku,
SUM(soi.qty_ordered) AS total_qty,
SUM(soi.row_total) AS total_revenue,
ROUND(AVG(soi.price), 2) AS avg_selling_price
FROM sales_order_item soi
JOIN sales_order so ON so.entity_id = soi.order_id
WHERE so.status = 'complete'
AND so.created_at >= CURDATE() - INTERVAL 30 DAY
GROUP BY soi.sku
HAVING total_qty >= 5
ORDER BY total_revenue DESC
LIMIT 50;
Mironsoft
SQL-Reporting für Magento korrekt aufbauen — ohne Fan-out, ohne falsches HAVING
Wir helfen dabei, GROUP BY-Queries korrekt zu schreiben, Performance-Probleme durch WHERE-statt-HAVING zu lösen und Magento-Reporting-Abfragen zuverlässig zu machen.
Query-Review
Aggregate Queries auf Korrektheit und Performance prüfen
Magento Reports
Verkaufs-, Kunden- und Produktberichte korrekt abbilden
SQL-Mode Analyse
ONLY_FULL_GROUP_BY Fehler diagnostizieren und beheben
GROUP BY SQL — Das Wichtigste auf einen Blick
Die Grundregel
Jede Nicht-Aggregat-Spalte im SELECT muss in GROUP BY stehen. ONLY_FULL_GROUP_BY erzwingt das — Magento setzt es voraus.
WHERE vor HAVING
WHERE filtert vor der Aggregation und nutzt Indexes. HAVING filtert nach der Aggregation — nie für Filterung verwenden, die WHERE erledigen kann.
Fan-out-Falle
Ein JOIN auf eine 1:N-Tabelle vor SUM() liefert aufgeblähte Werte. Lösung: in Subquery voraggregieren, dann joinen.
ROLLUP
WITH ROLLUP erzeugt automatisch Subtotals und Grand Totals. NULL in Gruppierungsspalten markiert die Aggregationszeilen.