PL/pgSQL-Funktions-Konventionen — Volatilität, RETURNS und die Grenze zur Prozedur

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 — IMMUTABLESTABLEVOLATILE und 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/LANGUAGE sauber 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.

KlasseVersprechenDer Planer darf …
IMMUTABLEGleiche 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.
STABLEInnerhalb 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.
VOLATILEKeine 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 — IMMUTABLEfn_get_project_name liest eine Tabelle, deren Inhalt sich zwischen Statements ändern kann — STABLE, nicht IMMUTABLEfn_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 IMMUTABLE markiert, obwohl sie eine Tabelle liest oder zeit-/sitzungsabhängige Werte nutzt — clock_timestamp()current_setting(), auch now() (das selbst nur STABLE ist und sich pro Transaktion ändert). Folge: Postgres cacht ein Ergebnis, das gar nicht konstant ist. Und weil der Planer dem IMMUTABLE-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 WerteIMMUTABLE ist 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 CALL als 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:

  1. 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 eine VOLATILE-Funktion mit Seiteneffekt tatsächlich läuft, ist nicht zuverlässig vorhersehbar.
  2. 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.
  3. 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>. Das fn_-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.
  • RETURNS und LANGUAGE je 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 festem RETURNS varchar kennt 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 (varcharbigintboolean, …) 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 ein RAISE EXCEPTION wirft, braucht ihn nicht.
  • Check parameter / Workload-Trennung optional. Die Aufteilung in zusammenklappbare BEGIN … 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

Was passiert, wenn ich die Volatilität nicht angebe?

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

Wann nehme ich eine Funktion, wann eine Prozedur?

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.

Darf eine Funktion in Postgres überhaupt schreiben?

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.

Soll eine Funktion alle Fehler mit 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.

Wie übertrage ich das auf SQL Server?

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