GRP
HAVING
SQL · MySQL · Aggregation · Magento · Performance
GROUP BY und HAVING sauber verstehen

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.

ONLY_FULL_GROUP_BY WHERE vs HAVING ROLLUP Fan-out-Falle

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.

FAQ: GROUP BY und HAVING in SQL

1Was bedeutet ONLY_FULL_GROUP_BY in MySQL?
ONLY_FULL_GROUP_BY ist ein SQL-Mode der erzwingt, dass alle Nicht-Aggregat-Spalten im SELECT auch in der GROUP BY-Klausel stehen. Ohne diesen Mode würde MySQL zufällige Werte aus der Gruppe wählen. Magento setzt diesen Mode voraus.
2Was ist der Unterschied zwischen WHERE und HAVING?
WHERE filtert Zeilen vor der Gruppierung und kann Indexes nutzen. HAVING filtert Gruppen nach der Aggregation — es kann Aggregat-Ausdrücke wie COUNT(*) oder SUM() filtern. Für nicht-aggregierte Filterungen immer WHERE bevorzugen.
3Warum gibt SUM() falsche Werte nach einem JOIN?
Das ist die Fan-out-Falle: Wenn eine 1:N-Beziehung gejoint wird, erscheinen Zeilen der Haupttabelle mehrfach im Ergebnis. SUM() addiert sie dann mehrfach. Lösung: In Subqueries voraggregieren, dann joinen.
4Was ist der Unterschied zwischen COUNT(*) und COUNT(column)?
COUNT(*) zählt alle Zeilen einer Gruppe inklusive NULL-Werte. COUNT(column) zählt nur Zeilen, wo der Wert nicht NULL ist. Für eindeutige Werte COUNT(DISTINCT column) verwenden.
5Wofür ist WITH ROLLUP gut?
WITH ROLLUP erweitert GROUP BY automatisch um Zwischensummen (Subtotals) und ein Gesamttotal. Die aggregierten Zeilen sind durch NULL-Werte in den Gruppierungsspalten erkennbar. COALESCE() hilft bei der Darstellung.
6Kann ich im HAVING Spalten-Aliase aus dem SELECT verwenden?
Ja, in MySQL können im HAVING Aliase referenziert werden, die im SELECT definiert wurden. Das ist eine MySQL-Erweiterung. Für portablen Code den vollständigen Ausdruck wiederholen.
7Wann brauche ich GROUP BY mit mehreren Spalten?
Wenn die Ausgabe nach einer Kombination von Werten gruppiert werden soll. Beispiel: Umsatz pro Kunde pro Monat braucht GROUP BY customer_email, YEAR(created_at), MONTH(created_at).
8Wie sortiere ich GROUP BY Ergebnisse?
Mit ORDER BY nach dem GROUP BY und HAVING. Man kann dabei Spalten-Aliase aus dem SELECT verwenden, Aggregatfunktionen oder Gruppierungsspalten.
9Was ist GROUP_CONCAT und wann verwende ich es?
GROUP_CONCAT aggregiert Stringwerte einer Gruppe zu einem kommaseparierten String. Nützlich um z.B. alle Produkt-SKUs einer Bestellung oder alle Coupons eines Kunden zusammenzufassen.
10Warum schlägt mein GROUP BY in Magento fehl?
Wahrscheinlich wegen ONLY_FULL_GROUP_BY. Eine Spalte im SELECT ist weder eine Aggregatfunktion noch in der GROUP BY-Klausel. Lösung: Die Spalte in GROUP BY aufnehmen oder durch eine Aggregatfunktion ersetzen.