warum es in Produktivcode konsequent vermieden wird
Kaum eine SQL-Gewohnheit ist so bequem und gleichzeitig so umstritten wie SELECT *. Für eine schnelle Abfrage in der Konsole spart der Stern Tipparbeit und liefert sofort alle verfügbaren Informationen. Sobald dieselbe Abfrage aber in Anwendungscode landet, der wiederholt ausgeführt wird und über Jahre gepflegt werden muss, kippt die Rechnung: unnötig übertragene Daten, stillschweigend brechender Code bei Schema-Änderungen und verschenktes Optimierungspotenzial durch verhinderte Covering-Indizes. Dieser Artikel geht die konkreten Probleme durch, benennt die legitimen Ausnahmen und zeigt, wie sich das Muster im Code Review zuverlässig abfangen lässt.
Inhaltsverzeichnis
- 1. Unnötiger Netzwerk- und I/O-Overhead durch überflüssige Spalten
- 2. Wie Schema-Änderungen bei SELECT * stillschweigend Anwendungscode brechen
- 3. Verhinderte Covering-Index-Nutzung als versteckter Performance-Verlust
- 4. Implizite Verträge und Typüberraschungen durch SELECT *
- 5. SELECT * bei JOINs: doppelte und mehrdeutige Spaltennamen
- 6. Legitime Ausnahmen, in denen SELECT * akzeptabel bleibt
- 7. Praktische Linting- und Review-Strategie gegen SELECT *
- 8. Auswirkung auf Abfrageplan-Caching und Statistiken
- 9. Migrationspfad für bestehenden Code mit vielen SELECT *-Stellen
- 10. Zusammenfassung
- 11. FAQ
1. Unnötiger Netzwerk- und I/O-Overhead durch überflüssige Spalten
Der offensichtlichste Nachteil von SELECT * ist, dass die Datenbank jede einzelne Spalte einer Tabelle liest und über die Verbindung an den Client überträgt, unabhängig davon, ob die Anwendung sie tatsächlich benötigt. Bei einer Tabelle mit einer großen Textspalte, etwa einer Produktbeschreibung oder einem serialisierten JSON-Blob, kann eine Abfrage, die eigentlich nur die ID und den Namen braucht, ein Vielfaches der tatsächlich benötigten Datenmenge übertragen.
Dieser Overhead summiert sich bei Abfragen, die häufig ausgeführt werden, spürbar auf: mehr Netzwerkbandbreite, mehr Serialisierungsaufwand auf der Datenbankseite, mehr Deserialisierung auf der Anwendungsseite, und mehr Speicherverbrauch für Objekte, die nie verwendet werden. Bei einer einzelnen Abfrage auf einer kleinen Tabelle ist das kaum messbar, bei tausenden Abfragen pro Sekunde auf einer breiten Tabelle wird daraus ein echter Kostenfaktor.
Besonders ins Gewicht fällt das bei Datenbank-I/O selbst: Wenn eine Zeile Spalten enthält, die nicht Teil des angefragten Index sind, muss die Datenbank zusätzlich zur Indexstruktur auch die eigentliche Tabellenzeile lesen, ein sogenannter Heap-Zugriff. Diese zusätzliche I/O-Operation entfällt komplett, wenn nur die tatsächlich benötigten, im Index bereits enthaltenen Spalten angefragt werden.
-- Überträgt auch große, ungenutzte Spalten wie description und metadata
SELECT * FROM products WHERE category_id = 42;
-- Überträgt nur die tatsächlich benötigten Spalten
SELECT id, name, price FROM products WHERE category_id = 42;
2. Wie Schema-Änderungen bei SELECT * stillschweigend Anwendungscode brechen
Ein subtileres, aber gefährlicheres Problem zeigt sich erst bei einer späteren Schema-Änderung. Wird der Tabelle eine neue Spalte hinzugefügt, liefert SELECT * ab sofort ein zusätzliches Feld, ohne dass sich am SQL-Code etwas ändert. Anwendungscode, der die Ergebniszeile anhand ihrer Position statt ihres Spaltennamens verarbeitet, etwa in vielen älteren Datenzugriffsschichten oder bei manuellem Array-Zugriff, verschiebt sich dadurch komplett und liest plötzlich falsche Werte in falsche Felder ein, oft ohne sofortigen Fehler.
Noch tückischer ist der umgekehrte Fall: Wird eine Spalte entfernt oder umbenannt, die der Anwendungscode über ihren Namen erwartet, schlägt der Zugriff erst zur Laufzeit fehl, wenn der entsprechende Codepfad tatsächlich durchlaufen wird, unter Umständen erst Wochen nach dem eigentlichen Schema-Change und weit entfernt vom Ort der Änderung. Eine explizite Spaltenliste hätte diesen Fehler sofort und lokal beim nächsten Deployment sichtbar gemacht, weil die Abfrage selbst fehlschlägt, statt stillschweigend falsche Daten zu liefern.
Dieses Problem verschärft sich in Microservice-Architekturen, in denen mehrere Dienste dieselbe Datenbank oder Tabelle lesen. Ein Team, das eine Spalte hinzufügt oder entfernt, hat oft keinen vollständigen Überblick darüber, welche anderen Dienste implizit über SELECT * davon betroffen sind, was Schema-Änderungen unnötig riskant macht.
-- Anwendungscode liest Spalten per Index-Position:
-- row[0] = id, row[1] = name, row[2] = email
SELECT * FROM users WHERE id = 7;
-- Wird "phone" als neue Spalte VOR "email" eingefügt,
-- landet die Telefonnummer plötzlich in row[2] statt der E-Mail-Adresse
-- Explizite Liste bleibt stabil, unabhängig von der Spaltenreihenfolge
SELECT id, name, email FROM users WHERE id = 7;
3. Verhinderte Covering-Index-Nutzung als versteckter Performance-Verlust
Ein Covering Index enthält alle Spalten, die eine Abfrage benötigt, sodass die Datenbank das Ergebnis vollständig aus dem Index selbst liefern kann, ohne die eigentliche Tabellenzeile zusätzlich lesen zu müssen. Das spart genau jenen Heap-Zugriff, der oben beschrieben wurde, und kann eine Abfrage um eine Größenordnung beschleunigen, insbesondere bei Tabellen, deren Zeilen nicht mehr komplett im Speicher-Cache liegen.
SELECT * verhindert diese Optimierung praktisch immer, weil es implizit jede Spalte der Tabelle anfragt. Selbst wenn ein Index alle für die WHERE-Klausel relevanten Spalten enthält, muss die Datenbank trotzdem die vollständige Zeile nachladen, sobald auch nur eine einzige zusätzliche Spalte verlangt wird, die nicht Teil des Index ist, und bei SELECT * ist das praktisch immer der Fall.
Eine explizite Spaltenliste erlaubt es dagegen, gezielt Indizes zu entwerfen, die exakt die in einer häufig ausgeführten Abfrage benötigten Spalten abdecken. Dieser Zusammenhang zwischen Abfrage-Design und Index-Design wird bei SELECT * praktisch unmöglich, weil die Menge der benötigten Spalten für die Datenbank gar nicht sichtbar ist, sie muss immer von allen ausgehen.
-- Covering Index für eine häufige Abfrage
CREATE INDEX idx_orders_customer_covering
ON orders(customer_id, status, total_cents);
-- Kann vollständig aus dem Index bedient werden, kein Heap-Zugriff
SELECT customer_id, status, total_cents
FROM orders WHERE customer_id = 501;
-- Erzwingt trotz passendem Index einen zusätzlichen Heap-Zugriff,
-- weil weitere Spalten wie shipping_address nachgeladen werden müssen
SELECT * FROM orders WHERE customer_id = 501;
4. Implizite Verträge und Typüberraschungen durch SELECT *
Ein weiteres, oft übersehenes Problem betrifft die Typsicherheit auf Anwendungsseite. In statisch typisierten Sprachen mit Code-Generierung aus dem Datenbankschema, etwa über ORM-Tools, erzeugt eine explizite Spaltenliste ein klares, überprüfbares Ergebnisobjekt. SELECT * erzeugt dagegen ein Ergebnisobjekt, dessen Form implizit vom aktuellen Tabellenschema abhängt, was Compile-Time-Prüfungen entwertet, wenn sich das Schema ändert, ohne dass der generierte Code neu erzeugt wird.
Auch bei UNION-Abfragen über mehrere Tabellen mit SELECT * entsteht ein zusätzliches Risiko: Beide Tabellen müssen exakt dieselbe Spaltenanzahl und kompatible Typen in derselben Reihenfolge liefern. Fügt eine der beiden Tabellen eine neue Spalte hinzu, während die andere unverändert bleibt, schlägt die UNION-Abfrage mit einem Fehler über eine falsche Spaltenanzahl fehl, ein Fehler, der mit einer expliziten, bewusst abgestimmten Spaltenliste von vornherein ausgeschlossen wäre.
Schließlich erschwert SELECT * auch das Lesen und Verstehen von Code im Review: Eine explizite Spaltenliste dokumentiert unmittelbar, welche Daten eine Funktion tatsächlich benötigt, während SELECT * diese Information verschleiert und einen Blick ins Schema erfordert, um zu verstehen, was die Abfrage eigentlich zurückliefert.
5. SELECT * bei JOINs: doppelte und mehrdeutige Spaltennamen
Bei einer Abfrage über mehrere Tabellen verschärft sich das Problem von SELECT * zusätzlich. Enthalten zwei gejointe Tabellen eine gleichnamige Spalte, etwa beide eine Spalte id oder created_at, liefert SELECT * beide Spalten unter demselben Namen zurück. Viele Datenzugriffsschichten, die das Ergebnis in ein assoziatives Array oder ein Objekt mit benannten Feldern abbilden, überschreiben dabei stillschweigend den Wert der ersten Spalte mit dem Wert der zweiten, ohne jede Fehlermeldung.
Dieses Verhalten ist besonders tückisch, weil es von der konkreten Reihenfolge der Tabellen im JOIN und der internen Verarbeitung des Datenbanktreibers abhängt und sich daher zwischen unterschiedlichen Datenbanksystemen oder sogar Treiberversionen unterscheiden kann. Ein Test, der lokal unauffällig grün bleibt, kann in einer anderen Umgebung plötzlich das falsche id-Feld liefern, ein Klassiker schwer reproduzierbarer Produktionsfehler.
Eine explizite Spaltenliste mit Tabellen-Alias und, wo nötig, individuellen Spalten-Aliassen löst dieses Problem grundsätzlich: order.id AS order_id und customer.id AS customer_id sind eindeutig benannt und können nie miteinander verwechselt werden, unabhängig von der Join-Reihenfolge oder dem verwendeten Datenbanktreiber.
-- Beide Tabellen haben eine Spalte "id": Ergebnis ist mehrdeutig
SELECT * FROM orders o
JOIN customers c ON c.id = o.customer_id;
-- Eindeutig benannt, unabhängig von Join-Reihenfolge oder Treiber
SELECT o.id AS order_id, c.id AS customer_id, o.total_cents
FROM orders o
JOIN customers c ON c.id = o.customer_id;
6. Legitime Ausnahmen, in denen SELECT * akzeptabel bleibt
Nicht jeder Einsatz von SELECT * ist ein Fehler. Beim Ad-hoc-Debugging in einer interaktiven Datenbank-Konsole, wenn ein Entwickler schnell den Inhalt einer unbekannten Tabelle sichten will, ist der Stern die pragmatische Wahl, weil hier weder Performance noch langfristige Wartbarkeit relevant sind, die Abfrage wird einmal ausgeführt und danach verworfen.
Auch bei EXISTS-Unterabfragen, bei denen der Datenbankoptimierer ohnehin erkennt, dass die konkrete Spaltenliste für das Ergebnis irrelevant ist, weil nur die Existenz einer Zeile geprüft wird, ist SELECT 1 oder SELECT * praktisch gleichwertig, da moderne Optimierer die Spaltenliste in diesem Kontext ignorieren und keine echten Daten übertragen.
Ein dritter legitimer Fall ist eine generische Backup-, Replikations- oder Migrationsroutine, deren gesamter Zweck darin besteht, wirklich jede Spalte einer Tabelle unverändert zu übernehmen. Hier ist SELECT * nicht nur akzeptabel, sondern sogar die korrektere Wahl, weil eine explizite Liste bei einer künftigen Schema-Änderung genau die neue Spalte übersehen würde, die eigentlich mitkopiert werden sollte.
7. Praktische Linting- und Review-Strategie gegen SELECT *
Statische SQL-Linter erkennen SELECT * zuverlässig als Muster und lassen sich in CI-Pipelines als Pflichtprüfung einbinden, die einen Merge-Request blockiert, solange die Regel verletzt wird. Viele SQL-Linter erlauben dabei eine gezielte Ausnahme-Kennzeichnung im Code, etwa über einen Kommentar, mit dem ein Entwickler bewusst begründet, warum an dieser Stelle SELECT * beabsichtigt ist, statt die Regel pauschal zu deaktivieren.
Für ORM-basierte Codebasen lohnt sich zusätzlich eine Konfigurationsprüfung, ob das ORM standardmäßig alle Spalten lädt, sogenanntes Eager Loading aller Felder, oder ob es projektionsbasiertes Laden unterstützt, bei dem nur explizit angeforderte Felder in die generierte Abfrage einfließen. Viele moderne ORMs bieten diese Option, sie ist aber häufig nicht die Standardeinstellung und muss aktiv konfiguriert werden.
Im Code Review selbst hilft eine einfache Faustregel: Jede neue oder geänderte Abfrage, die in wiederholt ausgeführtem Anwendungscode landet, sollte eine explizite Spaltenliste tragen, es sei denn, es handelt sich nachweislich um einen der oben genannten legitimen Ausnahmefälle. Diese Regel lässt sich in einem Team-Styleguide festhalten und im Review konsequent einfordern, statt sie von Fall zu Fall neu zu diskutieren.
-- Bewusste, dokumentierte Ausnahme statt pauschaler Regelverletzung
-- lint-disable-next-line select-star -- Backup-Routine, muss alle Spalten
-- inklusive künftiger Erweiterungen unverändert kopieren
SELECT * FROM archive_source;
8. Auswirkung auf Abfrageplan-Caching und Statistiken
Datenbanksysteme cachen häufig ausgeführte Abfragepläne, um wiederholte Optimierungsarbeit zu vermeiden. Bei SELECT * ist der resultierende Plan direkt an das aktuelle Schema gekoppelt: Ändert sich die Spaltenanzahl, muss der gecachte Plan verworfen und neu berechnet werden, was bei stark frequentierten Abfragen kurzzeitig zu erhöhter Last führen kann, unmittelbar nach jeder Schema-Änderung.
Bei einer expliziten Spaltenliste bleibt der Plan dagegen stabil, solange sich die referenzierten Spalten selbst nicht ändern, unabhängig davon, ob an anderer Stelle der Tabelle neue Spalten hinzukommen. Das reduziert die Zahl der Momente, in denen ein Schema-Deployment unerwartete Plan-Neuberechnungen und damit kurzzeitige Latenzspitzen auslöst.
Auch für die Statistikpflege spielt das eine Rolle: Ein Optimierer, der weiß, dass eine Abfrage nur drei bestimmte Spalten benötigt, kann gezielter entscheiden, für welche Spalten detaillierte Histogramme wichtig sind. SELECT * liefert hier keine solche Information und zwingt den Optimierer, konservativ von einem breiteren Zugriffsmuster auszugehen.
9. Migrationspfad für bestehenden Code mit vielen SELECT *-Stellen
In gewachsenen Codebasen mit hunderten SELECT *-Vorkommen lohnt sich kein abrupter Komplett-Umbau, sondern eine priorisierte Vorgehensweise. Zuerst werden Abfragen identifiziert, die in Hochlast-Pfaden liegen oder auf Tabellen mit großen, selten benötigten Spalten zugreifen, denn hier ist der Performance-Gewinn am größten. Diese werden zuerst umgeschrieben und mit einem Vorher-Nachher-Vergleich der übertragenen Datenmenge dokumentiert.
Für den restlichen Bestand hilft ein einfacher technischer Kunstgriff: Der Linter wird zunächst nur für neuen und geänderten Code aktiviert, nicht rückwirkend für die gesamte Codebasis. So entsteht kein Blockade-Aufwand für unveränderte Legacy-Abfragen, während jede zukünftige Änderung an einer Abfrage automatisch den Zwang zur expliziten Spaltenliste mitbringt und der Bestand organisch schrumpft.
Wichtig ist, diese Umstellung mit ausreichender Testabdeckung abzusichern, insbesondere wenn Anwendungscode bislang implizit von einer bestimmten Spaltenreihenfolge oder von zusätzlichen, ungenutzten Feldern abhängt. Ein automatisierter Vergleich der Antwortstruktur vor und nach der Umstellung verhindert, dass die Migration selbst neue, subtile Fehler einführt.
| Kriterium | SELECT * | Explizite Spaltenliste |
|---|---|---|
| Übertragene Datenmenge | immer alle Spalten | nur benötigte Spalten |
| Verhalten bei Schema-Änderung | stillschweigend andere Ergebnisform | Fehler bei fehlender Spalte, sofort sichtbar |
| Covering-Index-Nutzung | praktisch ausgeschlossen | gezielt möglich |
| Lesbarkeit im Code Review | Datenbedarf unklar | Datenbedarf explizit dokumentiert |
| Plan-Cache-Stabilität | bricht bei neuer Spalte | bleibt bei neuer Spalte stabil |
Mironsoft
Datenbank-Optimierung, Query-Tuning und Migrationen
SQL-Abfragen, die bei Wachstum immer langsamer werden?
Wir analysieren und optimieren SQL-Datenbanken unabhängig vom eingesetzten System, planen sichere Migrationen und Schema-Änderungen und bringen Teams Query-Optimierung praxisnah bei.
Query-Optimierung
Langsame Abfragen analysieren und mit Indizes und Explain-Plänen gezielt beschleunigen.
Migrations-Planung
Schema-Änderungen und Datenmigrationen sicher und ohne Downtime umsetzen.
Team-Schulung
SQL-Grundlagen und Performance-Denken praxisnah im Entwicklerteam verankern.
10. Zusammenfassung
SELECT * vermeiden: Das Wichtigste auf einen Blick
Kernproblem
SELECT * überträgt und dekodiert immer alle Spalten, unabhängig vom tatsächlichen Bedarf.
Größtes Risiko
Schema-Änderungen brechen positionsbasierten Anwendungscode stillschweigend.
Legitime Ausnahme
Ad-hoc-Debugging und generische Backup-/Replikationsroutinen.
Gegenmaßnahme
SQL-Linter in der CI-Pipeline mit dokumentierter Ausnahme-Kennzeichnung.