Dieser Artikel destilliert eine erprobte Sammlung von PL/pgSQL-Konventionen aus einem produktiven ETL-Generator-Projekt: ein Namens-Standard mit Präfixen, eine zusammenklappbare Block-Body-Struktur, das Prinzip „Argumente als Variablen statt Inline-Werte“ und das tabellarische Alignment, das grep und Code-Review erst effizient macht.
Das nimmst du mit:
- ein Präfix-System für Procedures, Functions, Trigger und Views (
sp_,fn_,tf_,tr_,vw_) plus Verb-Codes (ins/upd/del/dup/get); - die zusammenklappbare Block-Body-Struktur (
Get name -> Initialisierung -> Check parameter -> Workloadals Standard-Blöcke, erweiterbar) – und warum die Eingangsprüfungen vor der Mutation stehen müssen; - das Prinzip „Argumente als Variablen statt Inline-Werte“ – bei Fehler-Messages (
format($$...$$)) wie bei langen Parameter-Listen von Prozeduraufrufen; - tabellarisches Alignment für Parameter, Variablen und JOINs mit positionellen Aliasen (
T01,T02); - eine Datei-Nummerierung, die Tabellen-Gruppen abbildet statt eine globale Sequenz – und damit ohne Renummerier-Steuer auskommt;
- warum Trigger-Funktionen kein
DROP FUNCTIONbekommen; - wie aus dieser Konventions-Sammlung eine
.claude/rules/-Datei wird, die Claude Code bei jeder generierten SQL-Zeile auto-lädt und durchsetzt – der Sprung vom Style-Guide-PDF zur maschinen-erzwungenen Regel.
Voraussetzung: PostgreSQL 12+, Grundkenntnisse PL/pgSQL (CREATE PROCEDURE/FUNCTION, DECLARE, EXCEPTION-Handler).
Inhalt
- Warum PL/pgSQL-Konventionen?
- Namens-Standard
- Die Block-Body-Struktur
- Argumente als Variablen statt Inline-Werte
- Tabellarisches Alignment
- Datei-Nummerierung nach Tabellen-Gruppen
- Trigger-Funktionen: kein DROP
- Formatieren ist Verstehen, auch im Zeitalter von Copilot und Claude
- Konventionen als Skill-Datei: durchgesetzt statt dokumentiert
- Vorher und Nachher: eine Prozedur im Vergleich
- Zusammenfassung
- FAQ
- Verwandte Artikel
Warum PL/pgSQL-Konventionen?
Eine einzelne Stored Procedure ist immer lesbar – egal in welchem Stil sie geschrieben ist. Das Problem entsteht im Maßstab: 50, 100, 200 Objekte über fünf Entwickler hinweg. Ohne gemeinsames Vokabular weiß niemand, ob update_project eine Procedure, eine Function oder ein verirrter Application-Code-Name ist. Ohne tabellarisches Alignment kostet jedes Code-Review erst zehn Sekunden Augen-Sortierung, bevor die eigentliche Logik sichtbar wird. Ohne Datei-Konvention findet grep nicht, was eine Procedure anfasst.
PL/pgSQL-Konventionen sind damit kein Selbstzweck, sondern eine Investition in zwei wiederkehrende Operationen: Suchen (welches Objekt macht was?) und Lesen-im-Diff (was hat sich geändert?). Beide skalieren mit der Disziplin der Schreibweise. Die folgenden Regeln sind aus dieser Brille destilliert.
Namens-Standard
Jedes Datenbank-Objekt trägt ein Typ-Präfix. Das Präfix beantwortet die Frage „was ist das?“ bevor man den Namen liest:
| Objekt-Typ | Präfix | Beispiel |
|---|---|---|
| Stored Procedure | sp_ | sp_upd_table |
| Stored Function | fn_ | fn_is_null_or_empty |
| Trigger Function | tf_ | tf_table |
| Trigger | tr_ | tr_iud_table |
| View | vw_ | vw_execution_duration |
Bei Procedures folgt nach dem Präfix ein Verb-Code, dann die Entität:
ins= insert,upd= update,del= deleteget= select,exe= executedup= duplicate (eine Zeile in eine neue Zeile kopieren, mit neuem Surrogate-Key – für „Speichern als“-/Duplizieren-Flows, z. B.sp_dup_project)
Bei Triggern codiert der <type>-Teil die abgedeckten Events: i (insert), u (update), d (delete) oder die Kombination iud. Ein tr_iud_table feuert also auf alle drei.
Querschnitts-Regeln, die das Ganze konsistent halten:
- Immer
snake_case, immer Singular beim Tabellennamen (user,project,task– nieusers). FK-Spalten folgen natürlich:user_id, nichtusers_id. - Zeitstempel-Spalten enden auf
_on, nie_at:created_on,modified_on,last_login_on. Das TypeScript-Mapping übernimmt dasselbe Suffix (createdOn). - Jede Tabelle bekommt einen Surrogate-PK
id bigserial NOT NULLmitCONSTRAINT pk_<table> PRIMARY KEY (id). Natürliche Schlüssel werden zuUNIQUE-Constraints, nicht zum PK. - Parameter mit
p_präfixen (p_project_id,p_actor_email), lokale Variablen mitl_. Der Modus (IN/OUT/INOUT) trägt die Richtung – nicht der Name (p_result, nichtp_out_result). - Schemanamen nie hartkodieren – immer über Parameter oder psql-Variable (
:schema_app_name).
Ein Sonderfall verdient Erwähnung: die Tabelle heißt account, nicht user. PostgreSQL behandelt USER als reserviertes Keyword (Synonym für CURRENT_USER); ein nacktes FROM user parst als Funktionsaufruf. Statt sich überall auf Schema-Qualifizierung zu verlassen, weicht der Tabellenname aus – die Domänen-Sprache bleibt „User“, account ist ein DB-seitiger Workaround.
Ein Wort zur Einordnung: Nicht jede dieser Regeln ist ein Naturgesetz. snake_case, die p_/l_-Präfixe und ausgeschriebene Schema-Variablen sind handfeste Lesbarkeits- und Portabilitäts-Argumente. Das _on-Suffix, der durchgängige Surrogate-PK oder die account-Namensgebung sind dagegen Hausstil – verteidigbare Entscheidungen, über die ein anderes Team anders befinden könnte. Genau das ist aber der Punkt: Welche Form gewählt wird, ist zweitrangig – dass sie überall gleich gewählt wird, ist entscheidend. Erst die Konsequenz macht aus _on, p_ oder T0n ein verlässliches Suchmuster. Eine mittelmäßige Konvention, konsequent durchgehalten, schlägt die beste, die nur die Hälfte der Zeit greift.
Die Block-Body-Struktur
Jeder Procedure-/Function-Body gliedert sich in klar abgegrenzte Abschnitte, je mit einem 80-Bindestrich-Banner eingeleitet und – bis auf den ersten – in einen eigenen BEGIN ... END;-Sub-Block gelegt. Der Sub-Block hat nur einen Zweck: Er macht den Abschnitt im Editor zuklappbar, sodass man sich auf einen Teil konzentrieren kann. Die vier Standard-Blöcke:
1: BEGIN
2: -- --------------------------------------------------------------------------------
3: -- Get name of function/procedure
4: -- --------------------------------------------------------------------------------
5: SET LOCAL lc_messages TO 'C'; -- erzwingt englische Server-Meldungen
6: GET DIAGNOSTICS l_context = PG_CONTEXT;
7: l_component := substring(l_context from 'function (.*?)\(');
8:
9: RAISE NOTICE '### procedure : %', l_component;
10:
11: -- --------------------------------------------------------------------------------
12: -- Initialize variables
13: -- --------------------------------------------------------------------------------
14: BEGIN
15: l_status_new := 'active'; -- hartkodierte Initialisierung
16:
17: SELECT T01.default_role_code -- oder aus einer Tabelle gelesen
18: INTO l_default_role_code
19: FROM app.app_config T01
20: WHERE T01.config_key = 'default_role';
21: END;
22:
23: -- --------------------------------------------------------------------------------
24: -- Check parameter
25: -- --------------------------------------------------------------------------------
26: BEGIN
27: -- alle Eingangsprüfungen: Parameter-Validierung,
28: -- Actor-Context, Permission-Vorbedingungen
29: NULL;
30: END;
31:
32: -- --------------------------------------------------------------------------------
33: -- Workload
34: -- --------------------------------------------------------------------------------
35: BEGIN
36: -- die eigentliche Arbeit: Lookups, Mutationen, RETURN
37: NULL;
38: END;
39: END;
Get name– ermittelt den eigenen Funktionsnamen viaGET DIAGNOSTICS ... PG_CONTEXTfür aussagekräftige Fehler-Präfixe und einen Eingangs-RAISE NOTICE-Trace. Liegt direkt im äußerenBEGIN(kein Sub-Block). DerPG_CONTEXT-Kniff ist Komfort, kein Muss – wer ihn nicht braucht, lässt den Block weg oder setzt den Komponenten-Namen schlicht als Konstante.Initialisierung– Sub-Block, der die lokalen Variablen füllt, die der Rest der Prozedur braucht. Zwei Quellen: hartkodierte Startwerte (l_status_new := 'active') oder aus einer Tabelle gelesen viaSELECT ... INTO(z. B. ein Default aus einer Konfigurations-Tabelle).Check parameter– Sub-Block mit allen Eingangs- und Vorbedingungs-Prüfungen am Anfang: Parameter-Validierung, Actor-Context, Permission-Prüfungen.Workload– Sub-Block mit der eigentlichen Arbeit (Lookups, Mutationen, RETURN).
Vier Standard-Blöcke, aber keine feste Zahl. Das eigentliche Prinzip ist nicht „genau diese vier“, sondern: zusammengehörige Statements in einen mit BEGIN ... END; geklammerten, zuklappbaren Block legen, eingeleitet von einem Banner-Kommentar. Eine Prozedur darf so viele Blöcke haben, wie ihre Aufgabe verlangt – etwa einen eigenen Block pro Protokoll-Schritt (siehe Argumente als Variablen). Kleine Prozeduren kommen mit weniger aus; der Get name-Block bleibt immer direkt im äußeren BEGIN, alle weiteren sind Sub-Blöcke, die sich einzeln zuklappen lassen.
Die wichtigste Reihenfolge-Regel: Prüfungen stehen vor der Mutation. Der Check parameter-Block kommt immer vor dem Workload-Block. Beim Umbau bestehender Prozeduren darf die Reihenfolge sicherheitsrelevanter Prüfungen – allen voran Permission-Prüfungen – nie hinter die Mutation rutschen. Sonst schreibt die Procedure erst und prüft dann, ob sie überhaupt durfte.
Und ein praktischer Stolperstein: ein BEGIN END; ohne Statement ist in PL/pgSQL ein Syntaxfehler. Leere Gruppierungs-Blöcke brauchen mindestens ein NULL;.
Argumente als Variablen statt Inline-Werte
Ein wiederkehrendes Prinzip: Werte werden nicht direkt in einen Aufruf hineingeschrieben, sondern vorher je einer Variablen zugewiesen – der Aufruf bekommt nur noch Variablen. Das trennt das Was (welcher Wert) vom Dass (der Aufruf selbst) und hält die Aufruf-Zeile lesbar. Zwei Stellen, an denen sich das besonders auszahlt: Fehler-Messages und lange Parameter-Listen.
Fehler-Messages über format(). Hartkodierte Fehlertexte direkt am RAISE vermischen den Message-Aufbau mit dem Wurf-Akt. Die Konvention legt Message und Error-Code zuerst in Variablen und übergibt sie erst dann:
1: DECLARE
2: l_component text;
3: l_error_message text;
4: l_error_code text;
5: BEGIN
6: -- ...
7: IF NOT l_can_edit THEN
8: l_error_message := format($$%1$s: actor='%2$s' ist weder Owner noch Editor von Projekt id=%3$s$$, l_component, p_actor_email, l_project_id);
9: l_error_code := 'insufficient_privilege';
10:
11: RAISE EXCEPTION USING MESSAGE = l_error_message, ERRCODE = l_error_code;
12: END IF;
Die Regeln dahinter:
- Dollar-Quoting als Template-Delimiter (
format($$...$$, ...)) – dadurch bleiben die Hochkommas um Text-Werte einfach ('%2$s'statt verdoppelt). Sicher im Procedure-Body, weil der mit$procedure$...$procedure$quotet. - Nur indizierte Platzhalter
%1$s,%2$s,%3$s– niemals das nackte%. Das ist unverzichtbar, sobald ein Argument mehrfach in der Message vorkommt:%n$sreferenziert dasselbe Argument an mehreren Stellen, ohne es erneut zu übergeben. - Text-Werte in einfachen Hochkommas (
'%2$s'), damit Strings visuell abgegrenzt sind. Numerische Werte ohne Hochkommas. Der Komponenten-Präfix (l_component) bleibt ungequotet. - ERRCODE erhalten, nie erfinden. Hatte das Original keinen ERRCODE, bleibt es ohne.
Der Code liest sich dadurch von oben nach unten: erst was geworfen wird, dann dass geworfen wird. Eine Ausnahme bleiben die diagnostischen RAISE NOTICE-Traces (der ### procedure-Eingangs-Trace und der SQLERRM-Trace im Exception-Handler) – die sind Debug-Breadcrumbs, keine strukturierten Fehler, und bleiben einfaches Inline-RAISE NOTICE.
Lange Parameter-Listen bei Prozedur-/Funktionsaufrufen. Dasselbe Prinzip greift, sobald ein Aufruf mehr als zwei, drei Argumente hat. Inline-Werte direkt im CALL werden schnell zu einer kommagetrennten Wand, in der niemand mehr sieht, welcher Wert zu welchem Parameter gehört. Stattdessen wird vor dem Aufruf je eine Variable gesetzt – die Zuweisungen stehen tabellarisch untereinander, der Aufruf bekommt nur die Variablen:
1: -- Protokoll-Datensatz: Lauf-Start
2: l_log_run_id := p_run_id;
3: l_log_component := l_component;
4: l_log_action := 'load_start';
5: l_log_table := p_table_name;
6: l_log_message := format($$Start für Tabelle '%1$s'$$, p_table_name);
7:
8: CALL app.sp_ins_log_execution(l_log_run_id, l_log_component, l_log_action, l_log_table, l_log_message);
Der Aufwand ist real – jeder Wert kostet eine Deklaration und eine Zuweisung. Der Gewinn überwiegt: Der Aufruf wird zur immer gleichen Zeile, und der Block davor ist copy-paste-fähig. Ein Protokoll-Schritt sieht aus wie der nächste – Datensatz einfügen, später aktualisieren -, also kopiert man den Block und passt nur die Zuweisungen an, statt eine lange Argument-Liste neu zu sortieren. Bei gleichartigen Aufrufen (Logging, Audit-Inserts, wiederholte Status-Updates) ist das der Unterschied zwischen lesbarer Wiederholung und kommagetrennter Wand.
Tabellarisches Alignment
Hier liegt der größte Lese-Gewinn pro investierter Disziplin. Grundeinrückung ist app-weit drei Spaces. Die Sub-Spalten (Name, Typ) richten sich an einer gemeinsamen Spalte aus – Länge des längsten Bezeichners plus Abstand.
Wer schon mal einen Bericht in Excel gebaut hat, kennt das Prinzip bereits: Texte stehen linksbündig, Zahlen rechtsbündig, Beträge werden am Komma ausgerichtet, manches mittig. Niemand würde eine Zahlenspalte linksbündig stehen lassen – das Auge braucht die ausgerichtete Kante, um Werte zeilenweise zu vergleichen, statt jede Zelle einzeln zu lesen. Im Code ist es dieselbe Mechanik: ausgerichtete Spalten machen aus einer Liste von Deklarationen, Zuweisungen oder JOIN-Bedingungen eine Tabelle, die man auf einen Blick scannt – genau deshalb heißt es tabellarisches Alignment.
Parameter-Signaturen: ( und ) je auf eigener Zeile, Leading-Komma, das Modus-Keyword auf ein 6-Zeichen-Feld gepolstert:
1: CREATE OR REPLACE PROCEDURE :schema_app_name.sp_example
2: (
3: IN p_source_table_id bigint
4: ,IN p_actor_email varchar
5: ,INOUT p_result text
6: )
Variablen-Deklarationen: Datentyp in einer gemeinsamen Spalte ausgerichtet:
1: DECLARE
2: l_context varchar;
3: l_session_actor varchar;
4: l_error_message text;
Variablen-Initialisierung: Mehrere Zuweisungen als zusammenhängender Block – die := stehen untereinander, auch bei unterschiedlich langen Variablennamen:
1: l_run_id := p_run_id;
2: l_component := substring(l_context from 'function (.*?)\(');
3: l_table_name := p_table_name;
4: l_status_new := 'active';
Dieselbe Spalten-Flucht wie bei den Deklarationen: die ausgerichteten := zeigen auf einen Blick, dass hier ein zusammengehöriger Initialisierungs-Block steht, und erleichtern das Copy-Paste-Anpassen (siehe Argumente als Variablen).
JOINs verdienen besondere Erwähnung, weil hier die meisten Stile auseinanderlaufen:
1: FROM
2: app.project_member T01
3: INNER JOIN app.account T02
4: ON
5: T02.id = T01.account_id
6: WHERE
7: T01.project_id = l_project_id
8: AND T02.status = 'active'
- JOINs voll ausschreiben –
INNER JOINstatt nacktemJOIN. - Positionelle Aliase
T01,T02,T03für jede aliasierte Tabellen-Referenz, pro Statement neu abT01durchnummeriert. Das wirkt zunächst unintuitiv – sprechende Aliase wären doch lesbarer? In der Praxis nicht: unterschiedlich lange Aliase (pm/cp/sts) brechen die tabellarische Ausrichtung der Feldnamen, und bei vielen JOINs verlieren sprechende Namen ohnehin ihre Aussagekraft. GleichlangeT0nhalten die Spalten-Flucht. ONauf eigener Zeile, unter demINNERausgerichtet. Eine einzelne Bedingung zwei Spaces eingerückt; mehrere Bedingungen alsAND/OR-River mit führendem Operator und untereinander stehenden=.
Das vollständige Regelwerk für den Aufbau von SQL-Statements – Einrückung der Feldlisten, JOIN-Schreibweise, WHERE-River und die tabellarische Darstellung – ist ein Thema für sich und im Detail hergeleitet in Formatierung von SQL Statements (Teil 2) — Statement-Aufbau: SELECT, WHERE, FROM, JOIN. Hier geht es nur um die Ausrichtung innerhalb von Prozeduren-Code.
Datei-Nummerierung nach Tabellen-Gruppen
Jede DDL-Datei trägt ein 3-stelliges Nummern-Präfix: 003.sp_ins_project.sql. Der Clou: das Präfix ist kein globaler Sequenz-Counter, sondern ein Tabellen-Gruppen-Indikator.
- Eine Tabelle = eine Nummer. Alle Objekte einer Tabelle (Tabelle, Policies, Trigger-Function, Trigger, Procedures, Seed) teilen sich das Präfix.
003.sp_ins_project.sqlund003.sp_upd_project.sqlhaben bewusst dieselbe Nummer – disambiguiert wird über den Objekt-Namen, nicht die Nummer. - Nummern werden nie umverteilt. Wird eine Tabelle obsolet, bleibt ihre Nummer verbrannt.
Warum nicht ein globaler 001., 002., 003.-Counter? Drei konkrete Vorteile:
- Co-Location:
ls 007.*zeigt alle Objekte einer Tabelle auf einen Blick. - Keine Renummerier-Steuer: Neue Procedure für
project_member? Heißt004.sp_neue_procedure.sql, fertig – keine 20 anderen Dateien müssen umbenannt werden. - Dependencies löst das Deploy-Skript: Tables -> Policies -> Functions -> Procedures -> Triggers werden in getrennten Blöcken geladen. Die Nummer ist nur ein Sortier-Helfer fürs Dateisystem-Listing, keine semantische Lade-Reihenfolge.
Bei Cross-Table-Objekten (eine Procedure fasst mehrere Tabellen an) gilt eine Heuristik: Trigger-Funktion -> Präfix der Tabelle, an deren Trigger sie hängt. Procedure mit klarem Write-Target -> Präfix dieser Tabelle (Reads aus anderen Tabellen zählen nicht). Der --comment:-Header der Datei nennt jede Cross-Table-Beziehung explizit, damit grep findet, was sonst nur der Präfix-Filter zeigen würde.
Trigger-Funktionen: kein DROP
Ein letzter, oft schmerzhaft gelernter Punkt. Non-Trigger-Functions bekommen das DROP FUNCTION IF EXISTS ... (signatur); + CREATE OR REPLACE-Pattern. Trigger-Funktionen nicht:
1: \echo "## CREATE FUNCTION :schema_app_name.tf_table()"
2:
3: CREATE OR REPLACE FUNCTION :schema_app_name.tf_table()
4: RETURNS TRIGGER
5: LANGUAGE plpgsql
6: AS $triggerfunction$
7: BEGIN
8: -- Logic
9: END;
10: $triggerfunction$;
Der Grund: Trigger hängen an der Funktion. Ein DROP FUNCTION IF EXISTS bricht beim Re-Run mit cannot drop function ... because other objects depend on it (trigger ...) ab. CREATE OR REPLACE FUNCTION allein ist trigger-safe, solange die Signatur stabil bleibt – und bei RETURNS TRIGGER ohne Parameter ist sie das per Definition.
Im Trigger-Body selbst gilt: TG_OP mit IF / ELSIF / ELSE prüfen, immer alle drei Branches abdecken, ELSE -> RETURN NULL (kein implizites Durchfallen). Bei INSERT NEW.<column> übergeben und RETURN NEW, bei DELETE OLD.<column> und RETURN OLD. TG_OP wandert immer als erstes Argument in aufgerufene Procedures.
Formatieren ist Verstehen, auch im Zeitalter von Copilot und Claude
Es gibt Werkzeuge, die Layout automatisch erzeugen: pgFormatter für Postgres, sqlfluff als Linter und Formatter über mehrere Dialekte. Sie rücken ein, brechen Zeilen um, vereinheitlichen Groß-/Kleinschreibung – in Sekunden, ohne Disziplin. Warum dann überhaupt von Hand formatieren?
Weil Formatieren mehr ist als Layout. Der Akt des manuellen Einrückens, Ausrichtens und Block-Setzens zwingt einen, das Statement vollständig zu lesen und die Beziehungen zwischen den Tabellen mental aufzubauen. Wer eine Prozedur in Get name / Check parameter / Workload gliedert, muss entscheiden, was eine Prüfung ist und was die eigentliche Arbeit – und merkt dabei, wenn ein Permission-Check an der falschen Stelle steht (siehe das Vorher-Beispiel weiter unten). Auto-Formatter erzeugen das Layout, aber nicht dieses Verständnis.
Im Zeitalter von Copilot, Cursor und Claude Code ist das doppelt relevant. Generierter Code kommt fertig formatiert – und genau das ist die Falle: technisch korrektes SQL, das die fachliche Frage trotzdem nicht beantwortet. Konventionen helfen auf zwei Ebenen: Sie machen generierten Code lesbar genug, um ihn zu prüfen – und sie lassen sich, wie der nächste Abschnitt zeigt, der KI als Regel vorgeben, sodass sie von vornherein im Hausstil generiert. Das Verständnis aber bleibt beim Menschen.
Konventionen als Skill-Datei: durchgesetzt statt dokumentiert
Bis hierher war alles ein klassischer Style-Guide – gut, aber mit dem üblichen Problem: ein Style-Guide, den niemand liest, ist wirkungslos. Konventionen verrotten, sobald sie in einem Wiki liegen und auf Disziplin angewiesen sind. Genau hier setzt der entscheidende Schritt an: Diese ganze Sammlung lebt nicht als PDF, sondern als Datei .claude/rules/sql.md im Repository – und wird von Claude Code (dem KI-Coding-Agenten von Anthropic) bei jeder Session automatisch als Projekt-Instruktion geladen.
Der Unterschied ist fundamental. Ein menschlicher Entwickler kann den Style-Guide vergessen. Der Agent bekommt ihn bei jeder Anfrage vorgesetzt – und schreibt sp_upd_project statt update_project, rückt JOINs mit T01/T02 ein und legt Fehler-Messages in l_error_message-Variablen, weil die Regel das so vorgibt. Aus „bitte halte dich daran“ wird „so wird generiert“.
Was eine Regeldatei für einen Agenten anders macht als für einen Menschen
Eine Konvention für Menschen darf knapp sein – der Leser füllt Lücken mit Erfahrung. Eine Konvention für einen Agenten muss drei Dinge zusätzlich leisten, sonst driftet die Generierung:
- Explizite Beispiele statt Prosa. Statt „richte JOINs tabellarisch aus“ steht in der Datei der vollständige, korrekt eingerückte Code-Block – der Agent imitiert das Muster, das er sieht. Jede Regel ist mit einem Do-Beispiel verankert.
Don'tsals eigene Kategorie. Die Datei listet nicht nur, was zu tun ist, sondern explizit die Anti-Patterns: „DROP FUNCTION für Trigger-Funktionen“, „globaler Sequenz-Counter statt Tabellen-Gruppen-Nummer“. Negativ-Beispiele fangen genau die plausiblen Fehlgriffe ab, zu denen ein Modell sonst neigt.Single Source of Truth-Verweise. Wo eine Regel an mehreren Stellen gilt, zeigt die Datei auf die eine maßgebliche Quelle (z. B. „Lade-Reihenfolge bestimmt ausschließlichdeploy.sql, die Datei-Nummer ist nur ein Sortier-Helfer“). Das verhindert, dass der Agent zwei widersprüchliche Interpretationen mischt.
Der Aufbau, der das Durchsetzen trägt
Damit wird die Datei selbst zu einem kleinen Spezifikations-Format. Jede Regel folgt demselben Tripel: Regel -> Begründung -> Beispiel (Do/Don’t). Die Begründung ist dabei kein Beiwerk – sie sagt dem Agenten warum, sodass er die Regel auch auf neue, nicht explizit beschriebene Fälle übertragen kann. Ein Beispiel aus der sql.md:
Regel: Positionelle Aliase
T01,T02statt sprechender Namen. Begründung: unterschiedlich lange Aliase (pm/cp/sts) brechen die tabellarische Ausrichtung der Feldnamen; gleichlangeT0nhalten die Spalten-Flucht. Do: der ausgerichtete JOIN-Block. Don’t: der nackteJOINmitpm/cp.
Trifft der Agent später auf einen JOIN mit drei Tabellen, den die Datei nicht wörtlich zeigt, leitet er aus der Begründung die korrekte T03-Fortsetzung ab. Die Warum-Zeile ist die Generalisierungs-Brücke.
Was das im ETL-Alltag konkret bedeutet
Im produktiven Einsatz heißt das: neue Stored Procedures entstehen konvention-konform schon im ersten Wurf. Kein Code-Review, der erst Formatierung anmahnt, bevor die Logik dran ist. Keine fünf Stile über fünf Entwickler – der Agent ist der sechste, immer-disziplinierte „Entwickler“, der die Datei nie vergisst. Und wenn sich die Konvention ändert (etwa: ein neuer Verb-Code mrg für Merge-Operationen), wird eine Datei editiert – und ab der nächsten Session generiert der Agent nach der neuen Regel.
Das ist der eigentliche Hebel: Eine Konvention ist nur so viel wert wie ihre Durchsetzung. Als .claude/rules/-Datei wandert die Durchsetzung von „menschlicher Disziplin im Review“ zu „Default beim Generieren“ – und genau dort, am Entstehungs-Punkt des Codes, ist sie am billigsten.
Diese eine Konventionsdatei ist nur einer von drei Hebeln. Wie Rules, Skills und Agenten zusammen die KI-gestützte SQL-Entwicklung tragen, ordnet der Übersichtsartikel KI-gestützte SQL-Entwicklung mit Claude Code ein — dieser Beitrag ist die Fallstudie dazu.
Zum Mitnehmen: Die in diesem Artikel destillierten Konventionen gibt es als fertige Starter-Regeldatei zum Download – leg sie als .claude/rules/sql.md in dein Repo, und Claude Code zieht sie ab der nächsten Session als Projekt-Instruktion.
Die komplette Starter-Regeldatei gibt es als Download — leg sie als .claude/rules/sql.md ab.
Vorher und Nachher: eine Prozedur im Vergleich
Die einzelnen Regeln greifen erst im Zusammenspiel. Hier eine Prozedur, die die Rolle eines Projekt-Mitglieds ändert – einmal so, wie sie typischerweise schnell hingeschrieben wird, einmal nach den Konventionen dieses Artikels.
Vorher – läuft, ist aber schwer zu lesen und hat einen gefährlichen Fehler:
1: create or replace procedure update_member_role(pid bigint, aid bigint, newrole varchar, actor varchar)
2: language plpgsql as $$
3: declare canedit boolean;
4: begin
5: select exists(select 1 from project_member pm join account a on a.id=pm.account_id
6: where pm.project_id=pid and a.email=actor and pm.role_code in ('owner','editor')) into canedit;
7: update project_member set role_code=newrole, modified_on=now(), modified_by=actor
8: where project_id=pid and account_id=aid;
9: if not canedit then raise exception 'user % darf das nicht', actor; end if;
10: end; $$;
Drei Probleme: (a) Der Name update_member_role verrät nicht, dass es eine Procedure ist, und die Parameter (pid, aid, newrole) sind kryptisch. (b) Kein Block, kein Alignment – man muss jede Zeile einzeln entziffern. (c) Der gravierende: Die UPDATE (Zeile 7) läuft vor der Permission-Prüfung (Zeile 9). Die Prozedur schreibt erst und prüft danach, ob der Aufrufer überhaupt durfte.
Nachher – dieselbe Logik, konvention-konform:
1: CREATE OR REPLACE PROCEDURE :schema_app_name.sp_upd_project_member_role
2: (
3: IN p_project_id bigint
4: ,IN p_account_id bigint
5: ,IN p_role_code varchar
6: ,IN p_actor_email varchar
7: )
8: LANGUAGE plpgsql
9: AS $procedure$
10: DECLARE
11: l_context text;
12: l_component text;
13: l_can_edit boolean;
14: l_error_message text;
15: l_error_code text;
16: BEGIN
17: -- --------------------------------------------------------------------------------
18: -- Get name of procedure
19: -- --------------------------------------------------------------------------------
20: GET DIAGNOSTICS l_context = PG_CONTEXT;
21: l_component := substring(l_context from 'function (.*?)\(');
22:
23: -- --------------------------------------------------------------------------------
24: -- Check parameter
25: -- --------------------------------------------------------------------------------
26: BEGIN
27: SELECT EXISTS (
28: SELECT 1
29: FROM
30: app.project_member T01
31: INNER JOIN app.account T02
32: ON
33: T02.id = T01.account_id
34: WHERE
35: T01.project_id = p_project_id
36: AND T02.email = p_actor_email
37: AND T01.role_code IN ('owner', 'editor')
38: )
39: INTO l_can_edit;
40:
41: IF NOT l_can_edit THEN
42: l_error_message := format($$%1$s: actor='%2$s' darf Projekt id=%3$s nicht ändern$$, l_component, p_actor_email, p_project_id);
43: l_error_code := 'insufficient_privilege';
44:
45: RAISE EXCEPTION USING MESSAGE = l_error_message, ERRCODE = l_error_code;
46: END IF;
47: END;
48:
49: -- --------------------------------------------------------------------------------
50: -- Workload
51: -- --------------------------------------------------------------------------------
52: BEGIN
53: UPDATE app.project_member T01
54: SET
55: role_code = p_role_code
56: ,modified_on = now()
57: ,modified_by = p_actor_email
58: WHERE
59: T01.project_id = p_project_id
60: AND T01.account_id = p_account_id;
61: END;
62: END;
63: $procedure$;Was sich geändert hat, Punkt für Punkt: sp_upd_project_member_role trägt Präfix + Verb-Code und sagt damit, was es ist; die p_-Parameter sprechen für sich; die BEGIN ... END;-Blöcke trennen Prüfung von Arbeit und sind im Editor zuklappbar; die Permission-Prüfung steht jetzt vor der Mutation (Check parameter vor Workload); die Fehler-Message liegt in einer Variablen statt inline am RAISE; JOIN und WHERE sind mit T01/T02 tabellarisch ausgerichtet. Ein Initialisierung-Block fehlt hier bewusst – es gibt nichts vorzubelegen, und die Block-Struktur ist eben nicht auf eine feste Zahl festgelegt.
Beide Prozeduren tun exakt dasselbe. Die zweite kann man in 18 Monaten um 23 Uhr lesen – und sieht sofort, dass geprüft wird, bevor geschrieben wird.
Zusammenfassung
PL/pgSQL gibt einem fast keine Struktur vor – was Freiheit ist, solange ein Mensch eine Procedure schreibt, und ein Problem wird, sobald fünf Menschen 200 Objekte pflegen. Die hier gezeigte Sammlung von PL/pgSQL-Konventionen adressiert genau die zwei Operationen, die mit der Codebasis skalieren: Suchen (Präfixe, Tabellen-Gruppen-Nummerierung, Cross-Table-Header) und Lesen-im-Diff (Block-Body-Struktur, tabellarisches Alignment, Argumente-als-Variablen).
Take-Away:
- Präfix + Verb-Code (
sp_upd_project) beantwortet „was ist das?“ vor dem ersten Lesen des Namens.snake_case, Singular,_on-Suffix,p_/l_-Variablen sind die Querschnitts-Disziplin darunter. - Die zusammenklappbare Block-Body-Struktur (
Get name -> Initialisierung -> Check parameter -> Workloadals Standard-Blöcke, erweiterbar um weitere) macht Prozeduren zuklappbar und erzwingt: Prüfungen vor der Mutation, Permission-Prüfungen nie hinter den Write. - Argumente als Variablen statt Inline-Werte – bei Fehler-Messages (
format($$...$$)mit indizierten Platzhaltern%1$s) wie bei langen Parameter-Listen – trennt das Was vom Dass und macht gleichartige Aufrufe copy-paste-fähig. - Tabellarisches Alignment mit positionellen Aliasen (
T01) hält die Spalten-Flucht, die sprechende Aliase brechen würden. - Datei-Nummern bilden Tabellen-Gruppen ab, keine globale Sequenz – das spart die Renummerier-Steuer bei jeder neuen Procedure.
- Trigger-Funktionen bekommen kein
DROP–CREATE OR REPLACEallein ist trigger-safe. - Als
.claude/rules/sql.mdwird die ganze Sammlung von Claude Code auto-geladen und beim Generieren durchgesetzt – der Schlüssel dafür ist das Tripel Regel -> Begründung -> Do/Don’t-Beispiel, weil die Warum-Zeile dem Agenten die Generalisierung auf neue Fälle erlaubt.
FAQ
T01/T02 statt sprechender Namen wie pm/cp? Weil unterschiedlich lange Aliase die tabellarische Ausrichtung der Feldnamen brechen – und genau diese Ausrichtung ist der Lese-Gewinn. Gleichlange T0n halten die Spalten untereinander. Bei vielen JOINs verlieren sprechende Aliase ohnehin ihre Aussagekraft („war cp jetzt connection_profile oder column_property?“). Die Konvention tauscht eine vermeintliche Selbst-Dokumentation gegen verlässliche visuelle Struktur. Dieselbe Ausrichtungs-Logik liegt der Statement-Formatierung zugrunde – im Detail hergeleitet in Formatierung von SQL Statements (Teil 2).
Die Zahl der Blöcke richtet sich nach der Prozedur, nicht umgekehrt: Eine kleine Prozedur hat eben nur einen Workload-Block, eine reine Validator-Function ohne Fehler-RAISE (z. B. fn_validate_*, die nur Marker-Strings zurückgibt) spart Get name und Initialisierung ganz. Sobald aber eine Mutation im Spiel ist, lohnt sich die Trennung sofort: der Check parameter-Block ist die garantierte Stelle, an der jeder Reviewer zuerst nach den Permission-Prüfungen sucht. Diese Vorhersagbarkeit ist mehr wert als die paar eingesparten Banner-Zeilen – und die zugeklappten BEGIN ... END;-Blöcke machen selbst eine lange Prozedur auf Bildschirmhöhe überschaubar.
Auto-Formatter (pgFormatter, sqlfluff) erzeugen Layout, aber kein Verständnis. Das manuelle Strukturieren zwingt dich, das Statement vollständig zu lesen und die Tabellen-Beziehungen mental aufzubauen – genau dabei fällt auf, wenn ein Permission-Check an der falschen Stelle steht oder ein JOIN zu viele Zeilen liefert. Ein Formatter hätte den Code hübsch eingerückt und den Fehler mit-formatiert. Nutze die Tools gern zusätzlich für die mechanische Konsistenz – aber sie ersetzen weder das Lesen noch die Konventions-Entscheidungen (Präfixe, Block-Struktur, T0n-Aliase), die kein Formatter für dich trifft. Mehr dazu unter Formatieren ist Verstehen.
project_id braucht? Dann liegt die Grenze nach der letzten reinen Eingangsprüfung; jede Prüfung, die auf einem Lookup aufbaut, bleibt im Workload-Block. Die Sub-Blöcke klammern bestehende Statements an Ort und Stelle – es wird nichts umsortiert. Lieber ein kleinerer Check parameter-Block als eine riskante Umstellung, die versehentlich eine Permission-Prüfung hinter eine Mutation schiebt.
format($$...$$) und nicht einfach RAISE EXCEPTION 'text %', var? Drei Gründe. Erstens trennt die Variable das Was vom Dass – der Code liest sich von oben nach unten. Zweitens erlaubt das Dollar-Quoting einfache statt verdoppelte Hochkommas um Text-Werte. Drittens – und das ist der harte Vorteil – referenzieren indizierte Platzhalter (%2$s) dasselbe Argument an mehreren Stellen, ohne es erneut zu übergeben. Sobald eine Message einen Wert zweimal nennt, ist das nackte % nicht mehr wartbar.
Die Prinzipien (Präfixe, Prüfung-vor-Mutation, Argumente-als-Variablen, Alignment) sind engine-agnostisch. Die Syntax ist es nicht: T-SQL kennt kein format()-Äquivalent (man baut Strings mit FORMATMESSAGE oder CONCAT), keine Dollar-Quotes und ein anderes Trigger-Modell (AFTER/INSTEAD OF statt BEFORE/AFTER mit TG_OP). Die Konventions-Idee überträgt sich, die konkreten Code-Snippets nicht 1:1.
In drei Punkten: Erstens Beispiele statt Prosa – der Agent imitiert sichtbaren Code, also steht zu jeder Regel ein vollständiger Do-Block. Zweitens explizite Don'ts – Negativ-Beispiele fangen die plausiblen Fehlgriffe ab, zu denen ein Modell sonst tendiert (etwa DROP FUNCTION bei Trigger-Funktionen). Drittens eine Begründungs-Zeile pro Regel – das Warum ist die Brücke, über die der Agent eine Regel auf Fälle überträgt, die die Datei nicht wörtlich zeigt. Ein menschlicher Leser füllt diese Lücken mit Erfahrung; der Agent braucht sie ausgeschrieben. Praktisch landet die Datei unter .claude/rules/ im Repo und wird bei jeder Session als Projekt-Instruktion geladen – aus „bitte einhalten“ wird damit „so wird generiert“.
Verwandte Artikel
KI-gestützte Entwicklung:
SQL-Struktur & Formatierung:
- Formatierung von SQL Statements (Teil 1) — Bezeichner, Delimiter, Kommata, Aliase
- Formatierung von SQL Statements (Teil 2) — Statement-Aufbau: SELECT, WHERE, FROM, JOIN
- Strukturierung und Formatierung von SQL Statements
ETL-Kontext:
- Design Pattern // Architektur eines ETL-Prozesses
- Datenqualität in einem ETL-Prozess
- Design Pattern // Protokollierung eines ETL-Prozesses
Datenqualität & Typ-Konvertierung:
- Datenqualität // Grundlagen der Typ-Konvertierung mit T-SQL
- Design Pattern // Sichere Typ-Konvertierung mit T-SQL
- TRY_CONVERT für date, datetime, datetime2 und time
- TRY_CONVERT für decimal und numeric
- TRY_CONVERT für bigint, int, smallint und tinyint
- TRY_CONVERT für money und smallmoney
- TRY_CONVERT für float und real
- TRY_CONVERT für bit
/