Eine PL/pgSQL-Funktion ohne Volatilitäts-Marker ist per Default VOLATILE. Das klingt harmlos, kostet aber real: Der Planer ruft die Funktion pro Zeile erneut auf, berechnet sie nie einmalig vorab und schließt sie aus jedem funktionalen Index aus. Der Schaden ist unsichtbar — bis dieselbe Abfrage auf einmal Sekunden statt Millisekunden braucht. Gute PL/pgSQL-Funktions-Konventionen fangen genau hier an: nicht beim Naming, sondern bei der Frage, was die Funktion dem Planer verspricht.
Dieser Artikel ist der Funktions-Spoke einer kleinen Konventions-Serie. Die geteilten Body-Regeln (Drei-Block-Struktur, Fehler-Messages, tabellarisches Alignment) sind im Prozeduren-Artikel hergeleitet — hier geht es um das, was Funktionen von Prozeduren unterscheidet.
Das nimmst du mit:
- Volatilität richtig setzen —
IMMUTABLE,STABLE,VOLATILEund was der Planer daraus macht. - Die Grenze zur Prozedur — was in eine Funktion gehört und was nicht.
- Den RETURNS-Contract — Rückgabetyp als bewusster Vertrag,
RETURNS/LANGUAGEsauber gesetzt. - Den Validator-Leichtbau — wann der volle Prozedur-Body Overkill ist.
- Den
EXCEPTION → RETURN NULL-Fallback — wie Funktionen Fehler typisiert abfangen.
Voraussetzung: Postgres 12 oder neuer und PL/pgSQL-Grundkenntnisse. Die Beispiele laufen in einem neutralen Schema app; ersetze es durch dein eigenes.
Volatilität: IMMUTABLE, STABLE, VOLATILE
Das ist die wichtigste Entscheidung an einer Funktion — und die, die am häufigsten vergessen wird. Jede Funktion bekommt eine von drei Volatilitäts-Klassen. Sie ist kein Performance-Schalter, den man „später optimiert“, sondern ein Versprechen an den Planer, auf das er sich verlässt.
| Klasse | Versprechen | Der Planer darf … |
|---|---|---|
IMMUTABLE | Gleiche Argumente → immer gleiches Ergebnis. Kein DB-Zugriff, keine Zeit, keine Konfiguration. | den Aufruf schon zur Planzeit einmal berechnen und das Ergebnis als Konstante einsetzen, es cachen, funktionale Indizes darauf bauen. |
STABLE | Innerhalb eines Statements stabil: gleiche Argumente → gleiches Ergebnis. Darf lesend auf die DB zugreifen. | den Aufruf in einem Index-Scan nutzen und innerhalb eines Statements stärker optimieren als bei VOLATILE. |
VOLATILE | Keine Garantie. Kann pro Aufruf etwas anderes liefern, kann schreiben. | nichts annehmen — Aufruf pro Zeile, keine Vorab-Berechnung, kein Index. |
Der Default ist VOLATILE. Wer nichts angibt, bekommt die teuerste Variante. Für eine reine Berechnung ist das verschenkt.
Das Volatilitäts-Trio
Dieselbe Mechanik an drei Mini-Funktionen — jede mit der korrekten Klasse:
1: -- IMMUTABLE: reine Berechnung, keine Außenwelt
2: CREATE OR REPLACE FUNCTION app.fn_to_full_name
3: (
4: IN p_first varchar
5: ,IN p_last varchar
6: )
7: RETURNS varchar
8: LANGUAGE sql
9: IMMUTABLE
10: AS $function$
11: SELECT trim(concat_ws(' ', p_first, p_last));
12: $function$;
13:
14: -- STABLE: liest die DB, aber innerhalb eines Statements stabil
15: CREATE OR REPLACE FUNCTION app.fn_get_project_name
16: (
17: IN p_project_id bigint
18: )
19: RETURNS varchar
20: LANGUAGE sql
21: STABLE
22: AS $function$
23: SELECT name FROM app.project WHERE id = p_project_id;
24: $function$;
25:
26: -- VOLATILE: nicht-deterministisch (Sequenz-Seiteneffekt)
27: CREATE OR REPLACE FUNCTION app.fn_next_counter()
28: RETURNS bigint
29: LANGUAGE sql
30: VOLATILE
31: AS $function$
32: SELECT nextval('app.counter_seq');
33: $function$;
fn_to_full_name hängt nur von ihren Argumenten ab — IMMUTABLE. fn_get_project_name liest eine Tabelle, deren Inhalt sich zwischen Statements ändern kann — STABLE, nicht IMMUTABLE. fn_next_counter liefert bei jedem Aufruf einen neuen Wert — VOLATILE. Ein Sequenz-Zugriff ist ein völlig legitimer VOLATILE-Fall; sobald eine Funktion aber tatsächlich schreibt (INSERT/UPDATE/DELETE), ist das ein Hinweis, dass eine Prozedur besser passt (mehr dazu unten).
Die typischen Fehlklassifikationen
Zwei Fehler tauchen immer wieder auf:
- Zu optimistisch: Eine Funktion wird
IMMUTABLEmarkiert, obwohl sie eine Tabelle liest oder zeit-/sitzungsabhängige Werte nutzt —clock_timestamp(),current_setting(), auchnow()(das selbst nurSTABLEist und sich pro Transaktion ändert). Folge: Postgres cacht ein Ergebnis, das gar nicht konstant ist. Und weil der Planer demIMMUTABLE-Versprechen vertraut, lässt er einen funktionalen Index darauf überhaupt erst zu — der dann inkonsistent wird, sobald sich das Ergebnis ändert. Die Funktion liefert stillschweigend veraltete Werte.IMMUTABLEist ein Versprechen, kein Wunsch; wer es bricht, bekommt falsche Ergebnisse, keinen Fehler. - Zu pessimistisch: Eine reine Berechnung bleibt beim
VOLATILE-Default. Folge: keine Vorab-Berechnung zur Planzeit, keine Index-Nutzung, ein Aufruf pro Zeile. Für die Korrektheit unkritisch, aber bei großen Abfragen spürbar langsam.
Die Faustregel: So restriktiv wie wahr. Hängt die Funktion nur von ihren Argumenten ab → IMMUTABLE. Liest sie nur → STABLE. Schreibt oder ist nicht-deterministisch → VOLATILE (und prüfe, ob es nicht eine Prozedur sein sollte).
Was in eine Funktion gehört — und was nicht
Seit Postgres 11 gibt es echte Prozeduren (CREATE PROCEDURE, Aufruf über CALL). Damit ist eine Grenze ziehbar, die vorher verschwommen war:
- Funktionen berechnen und geben zurück. Sie werden idealerweise nur lesend oder rein berechnend aufgerufen — oft mitten in einer Abfrage (
SELECT app.fn_… FROM …). Ihr Rückgabewert ist der Zweck. - Prozeduren orchestrieren und schreiben. Sie werden über
CALLals eigenständige Aktion aufgerufen, dürfen Transaktionen steuern (COMMIT/ROLLBACK) und sind der richtige Ort für Mutationen.
Daraus folgt die Konvention: Schreibvorgänge gehören in der Regel in Prozeduren, nicht in Funktionen. Drei Gründe:
- Seiteneffekte mitten in einer Abfrage sind eine Falle. Eine Funktion, die in einem
SELECTüber 10 000 Zeilen aufgerufen wird, läuft pro Zeile — und je nach Plan (Filter vor der Funktion, Join-Reihenfolge, erneute Scans) öfter oder seltener, als man naiv erwartet. Wie oft eineVOLATILE-Funktion mit Seiteneffekt tatsächlich läuft, ist nicht zuverlässig vorhersehbar. - Der Aufrufpunkt verschleiert die Mutation.
SELECT app.fn_archive(id) FROM …sieht aus wie eine Leseoperation.CALL app.sp_archive(...)sagt ehrlich, dass hier etwas verändert wird. - Transaktions-Kontrolle gibt es nur in Prozeduren. Eine Funktion läuft immer in der Transaktion ihres Aufrufers und kann sie nicht selbst abschließen.
Das ist eine bewusste Setzung, kein Postgres-Verbot. Funktionen dürfen technisch schreiben (als VOLATILE), und vor Postgres 11 — ohne CREATE PROCEDURE — war das der einzige Weg. Die Regel ist eine Entscheidung mit Begründung: Wer die Grenze einhält, bekommt Code, dessen Lese- und Schreibpfade man am Aufruf erkennt. Die Body-Struktur für die Schreib-Seite ist im Prozeduren-Artikel hergeleitet.
Das Funktions-Skelett
Eine PL/pgSQL-Funktion mit Fehlerbehandlung folgt demselben Datei-Gerüst wie eine Prozedur, mit funktions-spezifischen Punkten. Das Skelett:
1: DROP FUNCTION IF EXISTS app.fn_is_null_or_empty(varchar, bigint);
2:
3: -- --------------------------------------------------------------------------------
4: -- Parameter
5: -- --------------------------------------------------------------------------------
6: -- p_value varchar
7: -- der zu prüfende Text-Wert
8: -- p_min_length bigint
9: -- Mindestlänge, ab der der Wert als "gefüllt" gilt
10: -- --------------------------------------------------------------------------------
11: CREATE OR REPLACE FUNCTION app.fn_is_null_or_empty
12: (
13: IN p_value varchar
14: ,IN p_min_length bigint
15: )
16: RETURNS varchar
17: LANGUAGE plpgsql
18: IMMUTABLE
19: AS $function$
20: DECLARE
21: l_returnvalue varchar;
22: BEGIN
23:
24: -- Logik
25:
26: RETURN l_returnvalue;
27:
28: EXCEPTION WHEN others THEN
29: RAISE NOTICE '##### %', SQLERRM;
30: RETURN NULL::varchar;
31: END;
32: $function$;
33:
34: ALTER FUNCTION app.fn_is_null_or_empty(varchar, bigint) OWNER TO app_owner;
Die Punkte, die eine Funktion von einer Prozedur abheben:
- Naming
fn_<verb>_<name>. Dasfn_-Präfix macht den Objekttyp am Namen erkennbar (Prozeduren:sp_, Trigger-Funktionen:tf_). snake_case durchgehend, wie im gesamten Konventions-Korpus. - Dollar-Quoting
$function$. Pro Objekttyp ein eigener Tag — Funktionen$function$, Prozeduren$procedure$. Das macht den Body beim Lesen sofort zuordenbar und vermeidet Kollisionen, wenn der Body selbst Dollar-Strings enthält. RETURNSundLANGUAGEje auf eigener Zeile (Zeile 16–17). Der Rückgabetyp steht damit isoliert und ist beim Überfliegen sofort sichtbar — er ist der Vertrag der Funktion (dazu gleich mehr).- Volatilität direkt darunter (Zeile 18). Sie gehört in den Kopf, nicht ans Ende — wer die Signatur liest, sieht sofort, was die Funktion verspricht.
- Der
-- Parameter-Doku-Block (Zeile 3–10) gehört in dieser Konvention auch bei Funktionen mit Parametern dazu. Pro Parameter Name + Typ, darunter die Bedeutung — die nackte Signatur reicht nicht. EXCEPTION WHEN others THEN → RETURN NULL::<typ>(Zeile 28–30). Tritt ein Fehler auf, wird er als Notiz protokolliert und ein typisierter NULL-Wert zurückgegeben statt durchgereicht. Der::varchar-Cast ist dabei Stil und Lesbarkeit — technisch nötig ist er nicht: Bei festemRETURNS varcharkennt Postgres den Zieltyp,RETURN NULL;würde genügen.
Vorsicht — WHEN others ist keine Universal-Endung. Alle Fehler abzufangen und NULL zurückzugeben passt dort, wo NULL ein sinnvolles „kein Ergebnis“ ist: bei berechnenden und prüfenden Funktionen, deren Vertrag genau das ist — eine ungültige Eingabe ergibt NULL statt eines Laufzeitfehlers. Das ist dasselbe Prinzip wie TRY_CONVERT in SQL Server (siehe die Datenqualitäts-Artikel dieses Blogs). Wo ein Fehler dagegen eine echte Störung ist, die der Aufrufer sehen muss, schadet das pauschale Schlucken: Die Ursache verschwindet, und ein „konnte nicht“ lässt sich nicht mehr von einem legitimen NULL unterscheiden. Dann lieber gezielt einzelne Fehler-Klassen abfangen oder mit RAISE; weiterreichen. Eine Prozedur setzt im EXCEPTION-Block typischerweise einen deterministischen Fehler-Status, statt den Fehler zu schlucken — auch das ein bewusster Kontrast.
Der RETURNS-Contract
Der Rückgabetyp ist kein Nebendetail, sondern der Vertrag der Funktion: Jeder Aufrufer verlässt sich darauf. Deshalb steht er auf eigener Zeile, und deshalb macht der Fallback im EXCEPTION-Block denselben Typ noch einmal sichtbar (NULL::varchar). Ein Rückgabetyp, der sich später ändert, bricht potenziell jeden aufrufenden Ausdruck — er wird wie eine API-Signatur behandelt, nicht wie eine Implementierungs-Frage.
Skalare Rückgaben (varchar, bigint, boolean, …) sind der Normalfall. Eine Funktion kann auch eine ganze Tabelle zurückgeben (RETURNS TABLE (…) oder SETOF) — das hat aber eigene Layout- und Performance-Regeln und ist ein eigenes Thema (dazu folgt ein separater Artikel über Funktionen mit Tabellen-Rückgabe).
Validator- und Helper-Functions: der Leichtbau
Nicht jede Funktion braucht das volle Gerüst. Reine Validator- und Helper-Functions — die nur rechnen oder prüfen und keinen Fehler werfen — dürfen schlanker sein:
1: CREATE OR REPLACE FUNCTION app.fn_normalize_email
2: (
3: IN p_email varchar
4: )
5: RETURNS varchar
6: LANGUAGE plpgsql
7: IMMUTABLE
8: AS $function$
9: BEGIN
10: RETURN lower(trim(p_email));
11: END;
12: $function$;
Was hier fehlt — und fehlen darf:
- Kein
Get name-Abschnitt. Die volle Body-Struktur aus dem Prozeduren-Artikel beginnt mit einem Diagnose-Block, der den Komponenten-Namen ermittelt (für Fehler-Messages und Logging). Eine Funktion, die nie einRAISE EXCEPTIONwirft, braucht ihn nicht. Check parameter/Workload-Trennung optional. Die Aufteilung in zusammenklappbareBEGIN … END;-Sub-Blöcke lohnt sich erst, wenn es echte Eingangsprüfungen und eine getrennte Arbeitsphase gibt. Bei einer Einzeiler-Berechnung wäre sie Ballast.- Oft kein
EXCEPTION-Block. Wenn die Berechnung nicht fehlschlagen kann, gibt es nichts abzufangen.
Solche Helper sind fast immer IMMUTABLE (reine Berechnung) und damit die Funktionen, die am meisten von der korrekten Volatilität profitieren — sie werden in Abfragen und funktionalen Indizes wiederverwendet. Der Leichtbau ist also kein Verzicht auf Qualität, sondern die passende Größe für den Zweck: So viel Struktur wie nötig, so wenig wie möglich.
So ist diese Konvention entstanden
Diese PL/pgSQL-Funktions-Konventionen sind nicht am Reißbrett entstanden, sondern aus echtem Code: Eine bestehende Sammlung von Funktionen wurde mit Claude Code analysiert, die wiederkehrenden Muster herausgezogen und zu einer Regeldatei verdichtet, die der Assistent beim Generieren neuer Funktionen automatisch durchsetzt — der Generate → Refine → Derive-Loop. Wie dieser Loop funktioniert und wie man aus eigenem Code eine durchsetzbare Konvention destilliert, ist im Methodik-Artikel beschrieben. Den größeren Rahmen — KI-gestützte SQL-Entwicklung mit Rules, Skills und Agenten — spannt der Überblicksartikel auf.
Zum Mitnehmen: Die Funktions-Konventionen aus diesem Artikel gibt es als fertige Starter-Regeldatei zum Download — leg sie als .claude/rules/functions.md in dein Repo, und Claude Code zieht sie ab der nächsten Session als Projekt-Instruktion.
FAQ
Dann ist die Funktion VOLATILE — der Postgres-Default. Der Planer behandelt sie als unvorhersehbar: ein Aufruf pro Zeile, keine Vorab-Berechnung, keine Nutzung in funktionalen Indizes. Für eine reine Berechnung ist das verschenkte Performance. Markiere bewusst, was wahr ist: IMMUTABLE für reine Berechnungen, STABLE für nur-lesende Funktionen
Faustregel: Funktion, wenn ein Wert berechnet und zurückgegeben wird, idealerweise lesend oder rein. Prozedur, wenn etwas geschrieben oder orchestriert wird und der Aufruf eine eigenständige Aktion ist (CALL). Funktionen werden oft mitten in Abfragen aufgerufen; Prozeduren stehen für sich.
Technisch ja — eine VOLATILE-Funktion kann INSERT/UPDATE/DELETE ausführen. Vor Postgres 11 (ohne CREATE PROCEDURE) war das der einzige Weg. Die hier vertretene Konvention rät trotzdem davon ab: Schreibvorgänge gehören in Prozeduren, weil Seiteneffekte mitten in einer Abfrage schwer vorhersehbar sind und den Aufrufpunkt verschleiern.
EXCEPTION WHEN others abfangen und NULL zurückgeben? Nur, wenn NULL ein sinnvolles „kein Ergebnis“ ist — bei berechnenden und prüfenden Funktionen (das TRY_CONVERT-Prinzip: ungültige Eingabe ergibt NULL statt Laufzeitfehler). Sonst nicht: Pauschales WHEN others → RETURN NULL schluckt die Ursache und macht ein „konnte nicht“ ununterscheidbar von einem echten NULL. Im Zweifel gezielt einzelne Fehler-Klassen abfangen oder mit RAISE; weiterreichen.
IMMUTABLE oder STABLE — woran erkenne ich den Unterschied? Frag dich: Hängt das Ergebnis nur von den Argumenten ab? Dann IMMUTABLE. Liest die Funktion irgendetwas aus der Datenbank oder aus der Umgebung (now(), current_setting(), eine Tabelle)? Dann höchstens STABLE, denn dieser Inhalt kann sich zwischen Statements ändern. IMMUTABLE auf etwas zu setzen, das doch liest, führt zu falschen gecachten Ergebnissen — nicht zu einem Fehler.
SQL Server kennt skalare benutzerdefinierte Funktionen (UDFs), aber kein IMMUTABLE-Schlüsselwort. Determinismus wird dort über WITH SCHEMABINDING und die Determinismus-Eigenschaft der verwendeten Ausdrücke bestimmt; seit SQL Server 2019 können skalare UDFs zudem ins Statement inlined werden. Das Grundprinzip — kennzeichne, worauf sich der Optimierer verlassen darf, und halte Schreibvorgänge aus berechnenden Funktionen heraus — überträgt sich, die Syntax nicht.
Verwandte Artikel
- KI-gestützte SQL-Entwicklung mit Claude Code — der Überblicksartikel, der Rules, Skills und Agenten zusammenführt.
- SQL-Konventionen mit Claude Code ableiten — die Methodik hinter dieser Konvention (Generate → Refine → Derive).
- PL/pgSQL-Prozeduren-Konventionen — das Geschwister-Stück: Body-Struktur, Fehler-Messages, Alignment.
- Postgres-Tabellen-Konventionen — Naming, Keys und Audit-Spalten für ein konsistentes Schema.