mit AVG() OVER() und ROWS BETWEEN
Ein Moving Average glaettet verrauschte Zeitreihen direkt in der Datenbank: mit AVG() OVER() und einem exakt definierten ROWS BETWEEN Fenster berechnest du gleitende Durchschnitte fuer Trendanalysen, ohne Rohdaten in eine Anwendung zu laden oder mit langsamen Self-Joins zu arbeiten.
Inhaltsverzeichnis
- 1. Was ein Moving Average in SQL leistet
- 2. AVG() OVER(): die Fensterfunktion als Basis
- 3. ROWS BETWEEN: den Fensterrahmen exakt definieren
- 4. Das N-Perioden-Muster fuer den gleitenden Durchschnitt
- 5. Zentrierter vs. nachlaufender Moving Average
- 6. Randbereiche: Start der Zeitreihe und NULL-Handling
- 7. Performance: Sortierung, Index und Ausfuehrungsplan
- 8. Anwendungsfall: Trendglaettung im Reporting-Dashboard
- 9. Moving Average im Vergleich: Window Function vs. Self-Join
- 10. Zusammenfassung
- 11. FAQ
1. Was ein Moving Average in SQL leistet
Rohdaten in Zeitreihen, etwa taegliche Umsaetze, Serverlast oder Sensordaten, schwanken stark von Periode zu Periode. Ein Moving Average, zu Deutsch gleitender Durchschnitt, glaettet dieses Rauschen, indem er fuer jeden Zeitpunkt den Durchschnitt ueber ein festes Fenster vorangegangener oder umgebender Werte berechnet. Statt eines einzelnen volatilen Tageswerts zeigt der Moving Average den zugrunde liegenden Trend, relativiert Ausreisser und macht saisonale Muster erst richtig sichtbar.
Klassisch wurde ein Moving Average in der Anwendungsschicht berechnet: Rohdaten aus der Datenbank laden, in Python, PHP oder Java iterieren, Summe ueber ein Fenster bilden, Ergebnis zurueckschreiben. Das ist unnoetiger Aufwand, wenn die Datenbank selbst ueber Window Functions verfuegt. Ein in SQL berechneter Moving Average bleibt naeher an den Daten, vermeidet Netzwerk-Overhead fuer grosse Rohdatenmengen und laesst sich direkt in Reporting-Views oder Materialized Views einbetten.
Dieser Artikel zeigt, wie AVG() OVER() zusammen mit einer praezisen ROWS BETWEEN Klausel jeden gewuenschten Moving Average erzeugt, welche Fallstricke bei Rand-Perioden auftreten und wie sich die Performance bei grossen Zeitreihen verhaelt.
2. AVG() OVER(): die Fensterfunktion als Basis
Die Fensterfunktion AVG() OVER() unterscheidet sich fundamental von der aggregierenden AVG()-Funktion mit GROUP BY. Waehrend GROUP BY Zeilen zu einer einzigen Ergebniszeile zusammenfasst, bleibt bei einer Window Function jede Ursprungszeile erhalten. AVG() OVER() berechnet fuer jede Zeile einen Durchschnitt ueber eine Menge verwandter Zeilen, das sogenannte Fenster, ohne die Anzahl der Zeilen im Ergebnis zu reduzieren. Genau diese Eigenschaft macht Window Functions zum richtigen Werkzeug fuer einen Moving Average: Jeder Tag behaelt seine eigene Zeile, bekommt aber zusaetzlich den geglaetteten Wert als weitere Spalte.
Die Syntax besteht aus drei Teilen: PARTITION BY gruppiert die Berechnung, zum Beispiel pro Filiale oder Produktkategorie, ORDER BY legt die Reihenfolge fest, in der das Fenster durchlaufen wird, und die Frame-Klausel definiert, welche Zeilen relativ zur aktuellen Zeile in die Berechnung einfliessen. Ohne explizite Frame-Klausel waehlt die Datenbank einen Standardrahmen, der fuer einen Moving Average fast nie der gewuenschte ist, weshalb die Frame-Klausel fuer ein korrektes Ergebnis unverzichtbar bleibt.
3. ROWS BETWEEN: den Fensterrahmen exakt definieren
ROWS BETWEEN definiert einen physischen Zeilenrahmen. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW bezieht exakt die sechs vorangegangenen Zeilen plus die aktuelle Zeile in den Moving Average ein, macht also ein Sieben-Tage-Fenster. Der Zusatz PRECEDING zaehlt Zeilen relativ zur aktuellen Position in der durch ORDER BY festgelegten Sortierung, unabhaengig davon, welchen Wert die Sortierspalte tatsaechlich traegt. Das unterscheidet ROWS von RANGE, das stattdessen Wertebereiche der Sortierspalte betrachtet. Fuer einen klassischen N-Perioden-Moving-Average ist ROWS fast immer die richtige Wahl.
Wichtig ist, dass die Frame-Klausel exakt zur ORDER BY-Spalte passt. Bei taeglichen Daten ohne Luecken liefert ROWS BETWEEN 6 PRECEDING AND CURRENT ROW ein sauberes Sieben-Tage-Fenster. Fehlen jedoch einzelne Tage in den Rohdaten, etwa weil an Wochenenden keine Umsaetze gebucht werden, bezieht sich ROWS weiterhin auf die Anzahl vorhandener Zeilen, nicht auf Kalendertage. Das Fenster kann dann mehr als sieben Kalendertage ueberspannen. Wer echte Kalenderfenster fuer einen Moving Average braucht, muss die Zeitreihe vorher mit einer Kalendertabelle auf luecklose Tage auffuellen.
4. Das N-Perioden-Muster fuer den gleitenden Durchschnitt
Das Standardmuster fuer einen N-Perioden-Moving-Average kombiniert AVG() mit OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN N-1 PRECEDING AND CURRENT ROW). Fuer einen Sieben-Tage-Moving-Average ueber taegliche Umsaetze pro Filiale sieht die Abfrage so aus:
-- 7-day moving average of daily revenue per store
SELECT
store_id,
sale_date,
daily_revenue,
AVG(daily_revenue) OVER (
PARTITION BY store_id
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d
FROM daily_sales
ORDER BY store_id, sale_date;
-- Sample result
-- store_id | sale_date | daily_revenue | moving_avg_7d
-- 1 | 2026-07-01 | 1200.00 | 1200.00
-- 1 | 2026-07-02 | 980.00 | 1090.00
-- 1 | 2026-07-03 | 1450.00 | 1210.00
-- 1 | 2026-07-04 | 1100.00 | 1182.50
-- 1 | 2026-07-05 | 1600.00 | 1266.00
-- 1 | 2026-07-06 | 1350.00 | 1280.00
-- 1 | 2026-07-07 | 1250.00 | 1275.71
-- 1 | 2026-07-08 | 1400.00 | 1304.29
Das Ergebnis enthaelt fuer jede Filiale und jeden Tag sowohl den rohen Tagesumsatz als auch den geglaetteten Sieben-Tage-Moving-Average in derselben Zeile. Fuer die ersten sechs Tage jeder Filiale ist das Fenster kleiner als sieben Zeilen, weil PRECEDING nicht ueber den Anfang der Partition hinausgreift. SQL liefert dann automatisch einen Durchschnitt ueber die tatsaechlich verfuegbaren Zeilen, keinen Fehler und kein NULL, was den Moving Average auch am Rand einer Zeitreihe robust macht.
5. Zentrierter vs. nachlaufender Moving Average
Ein nachlaufender, sogenannter trailing Moving Average wie im vorherigen Beispiel reagiert erst mit Verzoegerung auf Trendwechsel, weil er ausschliesslich vergangene Werte einbezieht. Das ist gewollt, wenn ein Reporting nur mit Daten bis zum aktuellen Tag arbeiten soll. Fuer eine rueckblickende Analyse historischer Daten, bei der auch zukuenftige Werte innerhalb der Zeitreihe bereits vorliegen, liefert ein zentrierter Moving Average ein saubereres Bild, weil er symmetrisch um die aktuelle Zeile herum glaettet.
Ein zentrierter Moving Average ersetzt ROWS BETWEEN 6 PRECEDING AND CURRENT ROW durch ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING, was ebenfalls ein Sieben-Zeilen-Fenster ergibt, aber symmetrisch um die aktuelle Zeile verteilt ist. Der Effekt: Spitzen und Einbrueche werden nicht erst mit Verzoegerung geglaettet, sondern bereits an ihrer tatsaechlichen Position in der Zeitreihe, was zentrierte Moving Averages besonders fuer die visuelle Trendanalyse in Dashboards attraktiv macht.
-- Centered 7-day moving average (3 rows before and after)
SELECT
store_id,
sale_date,
daily_revenue,
AVG(daily_revenue) OVER (
PARTITION BY store_id
ORDER BY sale_date
ROWS BETWEEN 3 PRECEDING AND 3 FOLLOWING
) AS centered_avg_7d
FROM daily_sales
ORDER BY store_id, sale_date;
-- Note: the last three rows per store have a shrinking window
-- because FOLLOWING cannot reach past the end of the partition
6. Randbereiche: Start der Zeitreihe und NULL-Handling
An beiden Enden einer Zeitreihe liefert die Frame-Klausel automatisch ein kleineres Fenster, statt NULL oder einen Fehler zu erzeugen. ROWS BETWEEN 6 PRECEDING AND CURRENT ROW berechnet fuer den allerersten Tag einer Partition den Moving Average schlicht ueber die eine verfuegbare Zeile. Das ist mathematisch korrekt, kann aber irrefuehrend sein, wenn ein Dashboard die ersten sechs Tage eines neuen Ladens genauso glatt darstellt wie etablierte Filialen mit vollem Fenster.
Wer diese verzerrten Randwerte im Reporting kennzeichnen will, zaehlt die tatsaechliche Fenstergroesse mit COUNT(*) OVER derselben Frame-Klausel mit und blendet Zeilen mit einer Fenstergroesse unterhalb der gewuenschten Periodenlaenge aus oder markiert sie separat. So bleibt der Moving Average technisch fuer alle Zeilen berechnet, waehrend das Reporting nur die vollstaendig gestuetzten Werte prominent zeigt.
-- Flag rows where the moving average window is not yet full
SELECT
store_id,
sale_date,
daily_revenue,
AVG(daily_revenue) OVER w AS moving_avg_7d,
COUNT(*) OVER w AS window_size,
CASE WHEN COUNT(*) OVER w < 7 THEN TRUE ELSE FALSE END AS is_partial_window
FROM daily_sales
WINDOW w AS (
PARTITION BY store_id
ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
)
ORDER BY store_id, sale_date;
-- Note: WINDOW clause (PostgreSQL, MySQL 8) avoids repeating
-- the same frame definition three times in one query
7. Performance: Sortierung, Index und Ausfuehrungsplan
Ein Moving Average ueber eine Window Function benoetigt intern eine Sortierung der Partition nach der ORDER BY-Spalte, bevor die Frame-Berechnung starten kann. Existiert bereits ein Index auf (store_id, sale_date), kann die Datenbank diese Sortierung aus dem Index lesen, statt sie zur Laufzeit im Arbeitsspeicher oder auf Platte durchzufuehren. Der Unterschied zeigt sich im Ausfuehrungsplan als Index Scan statt Sort vor der Window-Aggregation.
Bei sehr grossen Zeitreihen mit Millionen Zeilen pro Partition lohnt sich ein Blick auf EXPLAIN (ANALYZE) der jeweiligen Datenbank. Ein zusaetzlicher Sortierschritt vor der Window Function frisst Arbeitsspeicher proportional zur Partitionsgroesse und kann bei knappem work_mem in PostgreSQL oder sort_buffer_size in MySQL auf Festplatten-Temp-Dateien ausweichen, was den Moving Average deutlich verlangsamt. Ein zusammengesetzter Index, der Partitionsspalte und Sortierspalte in dieser Reihenfolge abdeckt, ist die wirksamste Einzelmassnahme fuer eine performante Berechnung.
8. Anwendungsfall: Trendglaettung im Reporting-Dashboard
In Reporting-Dashboards ist der Moving Average eines der meistgenutzten Elemente, weil er Tagesschwankungen zu einer verstaendlichen Trendlinie zusammenfasst. Ein klassisches Beispiel: Ein Umsatz-Dashboard zeigt neben dem taeglichen Rohumsatz zusaetzlich einen Sieben-Tage- und einen 28-Tage-Moving-Average als zwei ueberlagerte Linien. Die kurze Linie reagiert auf Wochentrends, die lange auf saisonale Verschiebungen, und der Abstand zwischen beiden Linien zeigt an, ob sich der Trend beschleunigt oder abschwaecht.
Weil beide Moving Averages in derselben Abfrage aus derselben Fensterfunktion mit unterschiedlicher Frame-Groesse berechnet werden koennen, entsteht kein zusaetzlicher Join und keine zweite Abfrage. Datenbank und Reporting-Tool sehen eine einzige, breite Ergebnistabelle mit mehreren Moving-Average-Spalten nebeneinander. Das reduziert die Komplexitaet in der Anwendungsschicht auf ein Minimum: Das Frontend muss lediglich die passenden Spalten in ein Liniendiagramm zeichnen, ohne selbst zu aggregieren.
-- Two moving averages (7-day and 28-day) in a single query
SELECT
store_id,
sale_date,
daily_revenue,
AVG(daily_revenue) OVER (
PARTITION BY store_id ORDER BY sale_date
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
) AS moving_avg_7d,
AVG(daily_revenue) OVER (
PARTITION BY store_id ORDER BY sale_date
ROWS BETWEEN 27 PRECEDING AND CURRENT ROW
) AS moving_avg_28d
FROM daily_sales
ORDER BY store_id, sale_date;
9. Moving Average im Vergleich: Window Function vs. Self-Join
Vor der Verbreitung von Window Functions in gaengigen Datenbanken wurde ein Moving Average haeufig ueber einen Self-Join oder eine korrelierte Subquery berechnet: Fuer jede Zeile wurde per Join oder Subquery erneut ueber die letzten N Zeilen aggregiert. Funktional entsteht dasselbe Ergebnis, die Ausfuehrung unterscheidet sich jedoch erheblich. Ein Self-Join ueber eine Bedingung wie sale_date BETWEEN aktueller_tag - 6 AND aktueller_tag erzeugt fuer jede Zeile einen eigenen Scan ueber bis zu N passende Zeilen, was bei grossen Tabellen zu quadratischer statt linearer Laufzeit fuehrt.
Eine Window Function dagegen sortiert die Partition einmal und schiebt dann ein Fenster inkrementell ueber die sortierten Zeilen, wobei viele Datenbanken die Summe des vorherigen Fensters wiederverwenden und nur die ein- und austretende Zeile anpassen. Das macht den Moving Average ueber AVG() OVER() nicht nur kuerzer im Code, sondern auch in nahezu allen Faellen deutlich schneller als die aequivalente Self-Join- oder Subquery-Variante.
-- Old approach: correlated subquery, avoid for large tables
SELECT
s1.store_id,
s1.sale_date,
s1.daily_revenue,
(
SELECT AVG(s2.daily_revenue)
FROM daily_sales s2
WHERE s2.store_id = s1.store_id
AND s2.sale_date BETWEEN s1.sale_date - INTERVAL '6 day' AND s1.sale_date
) AS moving_avg_7d
FROM daily_sales s1
ORDER BY s1.store_id, s1.sale_date;
-- Runs one subquery per output row, quadratic cost on large tables
| Ansatz | Performance | Lesbarkeit | Empfehlung |
|---|---|---|---|
| AVG() OVER() mit ROWS BETWEEN | Linear, ein Sortierschritt | Kompakt, eine Abfrage | Standardloesung fuer jeden Moving Average |
| Self-Join auf Datumsspalte | Quadratisch bei grossen Tabellen | Umstaendlich, zusaetzlicher Join | Nur bei fehlender Window-Function-Unterstuetzung |
| Korrelierte Subquery | Eine Subquery pro Zeile | Verstaendlich, aber ineffizient | Nur fuer sehr kleine Tabellen akzeptabel |
| Anwendungsseitige Berechnung | Netzwerk-Overhead fuer Rohdaten | Gut in der App, schlecht wiederverwendbar | Nur ohne Window-Function-Unterstuetzung der DB |
In der Praxis dominiert AVG() OVER() mit ROWS BETWEEN jeden anderen Ansatz fuer einen Moving Average deutlich, sowohl bei der Lesbarkeit als auch bei der Performance. Self-Joins und korrelierte Subqueries bleiben nur dort relevant, wo eine legacy Datenbank ohne Window-Function-Unterstuetzung im Einsatz ist, was bei modernen PostgreSQL-, MySQL-, SQL-Server- oder Oracle-Versionen praktisch nicht mehr vorkommt.
10. Zusammenfassung
Ein Moving Average direkt in SQL zu berechnen, reduziert sich auf ein festes Muster: AVG() als Window Function mit PARTITION BY, ORDER BY und einer expliziten ROWS BETWEEN Klausel. Ein nachlaufendes Fenster mit N-1 PRECEDING AND CURRENT ROW eignet sich fuer laufende Dashboards, ein zentriertes Fenster mit PRECEDING AND FOLLOWING fuer rueckblickende Analysen. An den Raendern der Zeitreihe liefert die Datenbank automatisch ein kleineres Fenster, was mit COUNT(*) OVER sichtbar gemacht werden kann.
Performance-seitig lohnt sich ein zusammengesetzter Index auf Partitions- und Sortierspalte, damit die Datenbank die fuer den Moving Average noetige Sortierung direkt aus dem Index liest. Verglichen mit einem Self-Join oder einer korrelierten Subquery ist die Window-Function-Variante nicht nur kuerzer, sondern bei grossen Zeitreihen auch strukturell schneller, weil sie linear statt quadratisch skaliert.
Moving Average in SQL, das Wichtigste auf einen Blick
Grundmuster
AVG() OVER (PARTITION BY ... ORDER BY ... ROWS BETWEEN N-1 PRECEDING AND CURRENT ROW) fuer einen N-Perioden-Moving-Average.
Nachlaufend vs. zentriert
PRECEDING AND CURRENT ROW fuer Dashboards mit aktuellen Daten, PRECEDING AND FOLLOWING fuer symmetrische, rueckblickende Analysen.
Randbereiche
Kein NULL an den Enden der Zeitreihe. Fenstergroesse mit COUNT(*) OVER pruefen, um verzerrte Randwerte zu kennzeichnen.
Performance
Index auf Partitions- und Sortierspalte. Linear statt quadratisch, deutlich schneller als Self-Join oder korrelierte Subquery.