Eine View ist die eleganteste Art, eine wiederkehrende Abfrage zu kapseln — bis zu dem Moment, in dem die Abfrage ein Argument von außen braucht. Eine View kennt keine Parameter. Sobald im WHERE ein Wert stehen soll, den der Aufrufer mitgibt, ist die tabellenwertige Funktion das Mittel der Wahl: eine Funktion, die statt eines einzelnen Werts eine ganze Ergebnismenge zurückgibt und sich aufrufen lässt wie eine Tabelle.
Genau an dieser Grenze entscheidet sich, ob ein Lesezugriff eine View, eine Funktion oder doch eine Prozedur sein sollte. Und es entscheidet sich, ob der Abfrage-Planer hindurchsieht — oder vor einer Blackbox steht.
Das Wichtigste vorab:
- Die Grenze: View, wenn die Menge ohne Parameter auskommt — Funktion, sobald sie Parameter braucht.
- Die Formen:
RETURNS TABLE(...),RETURNS SETOF <typ>undOUT-Parameter — wann welche. - Der Aufruf: in der
FROM-Klausel wie eine Tabelle, mitLATERALsogar zeilenabhängig. - Der Performance-Punkt:
LANGUAGE sqlkann der Planer wie eine View in die Abfrage inlinen (unter Bedingungen),plpgsqlbleibt eine Blackbox, deren Rumpf er nicht integriert.
Voraussetzung: PostgreSQL 12+ und PL/pgSQL-Grundkenntnisse. Das Beispiel-Schema heißt app, die Funktions-Namen folgen der fn_<verb>_<name>-Konvention.
Inhalt
- Wann überhaupt eine tabellenwertige Funktion — und nicht eine View
- RETURNS TABLE, SETOF und OUT-Parameter
- Aufruf: FROM und LATERAL
- Inlining: warum die Sprach-Wahl zählt
- SQL Server: inline vs. multi-statement TVF
- Projekt-Notiz: warum dieses Konventions-Regelwerk Views bevorzugt
- FAQ
- Verwandte Artikel
Wann überhaupt eine tabellenwertige Funktion — und nicht eine View
Vier Lese-/Schreib-Bedürfnisse, vier Werkzeuge. Die Trennlinie zwischen View und Funktion ist die Frage nach Parametern:
| Bedarf | Werkzeug |
|---|---|
| Einen Einzelwert zurückgeben | skalare Funktion |
| Eine Menge lesen — ohne Parameter | View |
| Eine Menge lesen — mit Parametern | tabellenwertige Funktion |
| Schreiben oder orchestrieren | Prozedur |
Eine View ist eine gespeicherte Abfrage ohne Eingang. Sie ist ideal für „gib mir immer dieselbe Sicht auf die Daten“ — etwa eine feste Umsatz-Aggregation:
1: CREATE VIEW app.vw_order_totals AS
2: SELECT
3: o.customer_id
4: ,count(*) AS order_count
5: ,sum(o.total) AS total_spent
6: FROM
7: app.orders o
8: GROUP BY
9: o.customer_id;
Will der Aufrufer dagegen filtern — „die Bestellungen dieses Kunden“, „die Umsätze dieser Region“ —, dann muss ein Wert von außen hinein. Das kann eine View nicht; das ist der Job der tabellenwertigen Funktion, und genau dieser Schritt ist der Rest des Artikels. Alles andere (ein Einzelwert, ein Schreibvorgang) gehört zu den anderen zwei Werkzeugen aus der Tabelle und ist hier nicht das Thema.
RETURNS TABLE, SETOF und OUT-Parameter
Postgres kennt drei Schreibweisen für eine Funktion, die eine Menge zurückgibt. Die häufigste und lesbarste ist RETURNS TABLE(...) — die Rückgabe-Struktur steht direkt in der Signatur:
1: CREATE OR REPLACE FUNCTION app.fn_get_orders_by_customer
2: (
3: p_customer_id bigint
4: )
5: RETURNS TABLE
6: (
7: order_id bigint
8: ,ordered_at timestamptz
9: ,total numeric(12, 2)
10: )
11: LANGUAGE plpgsql
12: STABLE
13: AS $function$
14: BEGIN
15: RETURN QUERY
16: SELECT
17: o.order_id
18: ,o.ordered_at
19: ,o.total
20: FROM
21: app.orders o
22: WHERE
23: o.customer_id = p_customer_id;
24: END;
25: $function$;
RETURN QUERY schiebt das Ergebnis einer Abfrage als Rückgabe-Menge heraus; mehrere RETURN QUERY hintereinander hängen ihre Mengen an. (Das zeilenweise RETURN NEXT gibt es auch — es ist für den seltenen Fall gedacht, dass man jede Zeile einzeln berechnet, und ist fast immer langsamer als ein RETURN QUERY.)
Die zwei Alternativen kurz:
RETURNS SETOF <typ>gibt ganze Zeilen eines vorhandenen Typs zurück — meist einer Tabelle:RETURNS SETOF app.orders. Praktisch, wenn die Funktion genau die Spalten einer Tabelle liefert; man wiederholt die Spaltenliste nicht.OUT-Parameter sind die dritte Form:(IN p_customer_id bigint, OUT order_id bigint, OUT total numeric)ergibt implizit eine Menge. Funktional äquivalent zuRETURNS TABLE, aber weniger augenfällig —RETURNS TABLEist die selbsterklärende Wahl.
Eine Falle bei RETURNS TABLE + plpgsql: Die Spaltennamen aus der TABLE(...)-Klausel werden zu Variablen im Funktionskörper. Heißt eine Tabellen-Spalte genauso (order_id), wird ein unqualifiziertes order_id mehrdeutig und Postgres bricht ab. Deshalb im Körper die Tabellen-Spalten konsequent mit Alias qualifizieren (o.order_id) — wie oben in den Zeilen 17–23.
Aufruf: FROM und LATERAL
Eine tabellenwertige Funktion steht da, wo eine Tabelle stehen darf — in der FROM-Klausel:
1: -- Aufruf wie eine Tabelle
2: SELECT * FROM app.fn_get_orders_by_customer(1);
3:
4: -- LATERAL: die Funktion pro Zeile mit zeilenabhängigem Argument aufrufen
5: SELECT
6: c.customer_id
7: ,o.order_id
8: ,o.total
9: FROM
10: app.customer c
11: CROSS JOIN LATERAL app.fn_get_orders_by_customer(c.customer_id) o
12: WHERE
13: c.region = 'EU';
Der erste Aufruf ist trivial. Der zweite ist der eigentliche Grund, warum Funktionen mächtiger sind als Views: LATERAL erlaubt, der Funktion ein Argument aus der vorherigen Zeile zu übergeben (c.customer_id). So ruft die Abfrage die Funktion einmal pro Kunde auf und verbindet die Ergebnisse — etwas, das eine parameterlose View nicht leisten kann.
Inlining: warum die Sprach-Wahl zählt
Hier liegt der Performance-Kern, und er hängt an einer einzigen Zeile: LANGUAGE sql oder LANGUAGE plpgsql. Schreibt man dieselbe Logik als reine SQL-Funktion, kann der Planer sie inlinen — er ersetzt den Funktionsaufruf durch ihren Rumpf und optimiert die Gesamtabfrage, als stünde dort eine View (unter Bedingungen, dazu gleich). Die plpgsql-Variante bleibt dagegen eine Blackbox: Der Planer kann ihren Rumpf nicht in die umgebende Abfrage integrieren — er kennt nur die deklarierten Eigenschaften (Cost, ROWS, Volatilität), nicht den Inhalt.
Dieselbe Abfrage als LANGUAGE sql:
1: CREATE OR REPLACE FUNCTION app.fn_get_orders_by_customer_sql
2: (
3: p_customer_id bigint
4: )
5: RETURNS TABLE
6: (
7: order_id bigint
8: ,ordered_at timestamptz
9: ,total numeric(12, 2)
10: )
11: LANGUAGE sql
12: STABLE
13: AS $function$
14: SELECT
15: o.order_id
16: ,o.ordered_at
17: ,o.total
18: FROM
19: app.orders o
20: WHERE
21: o.customer_id = p_customer_id;
22: $function$;
Der Beweis steht im Ausführungsplan. Die LANGUAGE sql-Funktion verschwindet — der Planer scannt direkt die Tabelle samt Filter:
EXPLAIN SELECT * FROM app.fn_get_orders_by_customer_sql(1);
Seq Scan on orders o (cost=0.00..25.00 rows=6 width=48)
Filter: (customer_id = '1'::bigint)
Die plpgsql-Funktion dagegen erscheint als eigener Function Scan — und mit einer Pauschal-Schätzung von 1000 Zeilen, weil der Planer die echte Menge nicht kennt:
EXPLAIN SELECT * FROM app.fn_get_orders_by_customer(1);
Function Scan on fn_get_orders_by_customer (cost=0.25..10.25 rows=1000 width=48)
Diese 1000 ist der Default, solange keine explizite ROWS <n>-Angabe in der Signatur steht. Schätzt der Planer 1000 Zeilen, wo in Wahrheit 2 kommen (oder umgekehrt), entstehen schlechte Join-Reihenfolgen und falsche Strategien. Für reine Lese-Mengen ist LANGUAGE sql deshalb fast immer die bessere Wahl — inlinebar, transparent, ohne Schätz-Risiko.
Inlining passiert aber nicht automatisch, nur weil LANGUAGE sql draufsteht — es ist an Bedingungen geknüpft: Die Funktion muss ein einzelnes SELECT sein, als STABLE oder IMMUTABLE deklariert (eine VOLATILE-Funktion wird nicht inlined) und ohne SECURITY DEFINER. Das STABLE aus dem Beispiel ist damit keine Kosmetik, sondern Vorbedingung: Es macht die Funktion überhaupt erst inlinebar — und erlaubt dem Planer zugleich, sie frei umzustellen.
Nicht absolut nehmen: plpgsql ist nötig, sobald echte prozedurale Logik ins Spiel kommt — Schleifen, Bedingungen, mehrere aufeinander aufbauende Statements, Fehlerbehandlung. Dann ist die Blackbox der Preis für die Ausdruckskraft, und man hilft dem Planer mit einer realistischen ROWS <n>-Angabe in der Signatur nach. Die Faustregel: ein einzelnes SELECT → LANGUAGE sql; echte Logik → plpgsql.
SQL Server: inline vs. multi-statement TVF
Wer aus der SQL-Server-Welt kommt, kennt die Unterscheidung bereits — sie heißt dort nur anders. SQL Server trennt inline Table-Valued Functions (eine einzige RETURN (SELECT …)) von multi-statement TVFs (mit einer @table-Variablen, die zeilenweise gefüllt wird). Die Parallele ist fast eins zu eins:
- inline TVF ↔
LANGUAGE sql— single-statement, vom Optimierer durchschaubar, inlinebar. - multi-statement TVF ↔
plpgsql— prozedural, eine Blackbox mit Schätzung.
Die geteilte Lehre über beide Engines hinweg: Lass den Optimierer hindurchsehen, wo es geht. Multi-statement-TVFs galten in SQL Server lange als Performance-Falle (bis das Inlining ab SQL Server 2019 nachzog) — aus genau demselben Grund, aus dem eine plpgsql-Tabellen-Funktion in Postgres eine Schätzung statt echter Statistik bekommt.
Projekt-Notiz: warum dieses Konventions-Regelwerk Views bevorzugt
Ein kurzer Schritt aus dem allgemeinen Postgres-Stoff heraus — die Konventions-Perspektive, aus der dieser Artikel stammt.
In dem Regelwerk, um das es in dieser Artikel-Serie geht (der KI-gestützten SQL-Entwicklung mit Claude Code), kommen tabellenwertige Funktionen bewusst kaum vor: Parameterlose Lese-Mengen laufen dort grundsätzlich über Views (vw_<name>, nur lesend, idempotent), nicht über Funktionen.
Das widerspricht dem Artikel nicht — es ist seine Pointe in einem Satz: Die View ist die erste Wahl, solange keine Parameter nötig sind (inlinebar, idempotent, voll durchschaubar); die tabellenwertige Funktion ist das Werkzeug für genau den Rest. Wer zuerst zur Funktion greift, wo eine View gereicht hätte, verschenkt Transparenz für nichts. Wie solche Konventionen entstehen und durchgesetzt werden, zeigt der Methodik-Artikel zum Ableiten von SQL-Konventionen.
FAQ
RETURNS TABLE oder SETOF — was ist der Unterschied? RETURNS TABLE(...) definiert die Rückgabe-Spalten direkt in der Signatur — gut lesbar und unabhängig von vorhandenen Typen. RETURNS SETOF <typ> gibt ganze Zeilen eines bestehenden Typs zurück, meist einer Tabelle (SETOF app.orders) — kompakt, wenn die Funktion genau deren Spalten liefert. Funktional überlappen sie sich; RETURNS TABLE ist die selbsterklärende Standardwahl.
plpgsql-Tabellen-Funktion langsamer als eine View? Weil der Planer nicht in sie hineinsieht. Eine plpgsql-Funktion erscheint im Plan als Function Scan mit einer pauschalen Schätzung von 1000 Zeilen; er kann sie nicht mit dem Rest der Abfrage gemeinsam optimieren. Eine View — oder eine LANGUAGE sql-Funktion — wird dagegen inlined und voll optimiert. Für reine Lesezugriffe ist LANGUAGE sql oder eine View fast immer schneller.
Nein, nicht im eigentlichen Sinn. Genau das ist die Grenze, an der die tabellenwertige Funktion anfängt. (Es gibt Umwege über current_setting() oder Session-Variablen, aber das ist ein Hack, keine saubere Parametrisierung — wer Parameter braucht, nimmt eine Funktion.)
Eine inline TVF in SQL Server entspricht einer LANGUAGE sql-Funktion in Postgres (single-statement, inlinebar), eine multi-statement TVF entspricht einer plpgsql-Funktion (prozedural, opak mit Schätzung). Die Performance-Lehre ist in beiden Engines dieselbe: bevorzuge die Form, durch die der Optimierer hindurchsieht.
Verwandte Artikel
Dieser Artikel ist ein Reference-Spoke des Clusters KI-gestützte SQL-Entwicklung mit Claude Code:
- KI-gestützte SQL-Entwicklung mit Claude Code — der Überblick (Pillar)
- SQL-Konventionen mit Claude Code ableiten — der Generate-Refine-Derive-Loop
- PL/pgSQL-Funktions-Konventionen — Volatilität, RETURNS und die Grenze zur Prozedur — die skalaren Funktionen, aus denen dieser Artikel ausgegliedert wurde
- SQL-Konventionen // PL/pgSQL-Prozeduren, die man in zwei Jahren noch lesen kann — das Schreib-/Orchestrierungs-Werkzeug
- Postgres-Tabellen-Konventionen — Naming, Keys und Audit-Spalten