SQL-Konventionen // PL/pgSQL-Prozeduren, die man in zwei Jahren noch lesen kann

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 -> Workload als 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 (T01T02);
  • eine Datei-Nummerierung, die Tabellen-Gruppen abbildet statt eine globale Sequenz – und damit ohne Renummerier-Steuer auskommt;
  • warum Trigger-Funktionen kein DROP FUNCTION bekommen;
  • 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/FUNCTIONDECLAREEXCEPTION-Handler).

Inhalt

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-TypPräfixBeispiel
Stored Proceduresp_sp_upd_table
Stored Functionfn_fn_is_null_or_empty
Trigger Functiontf_tf_table
Triggertr_tr_iud_table
Viewvw_vw_execution_duration

Bei Procedures folgt nach dem Präfix ein Verb-Code, dann die Entität:

  • ins = insert, upd = update, del = delete
  • get = select, exe = execute
  • dup = 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 (userprojecttask – nie users). FK-Spalten folgen natürlich: user_id, nicht users_id.
  • Zeitstempel-Spalten enden auf _on, nie _atcreated_onmodified_onlast_login_on. Das TypeScript-Mapping übernimmt dasselbe Suffix (createdOn).
  • Jede Tabelle bekommt einen Surrogate-PK id bigserial NOT NULL mit CONSTRAINT pk_<table> PRIMARY KEY (id). Natürliche Schlüssel werden zu UNIQUE-Constraints, nicht zum PK.
  • Parameter mit p_ präfixen (p_project_idp_actor_email), lokale Variablen mit l_. Der Modus (IN/OUT/INOUT) trägt die Richtung – nicht der Name (p_result, nicht p_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 userPostgreSQL 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 _onp_ 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;
 

  1. Get name – ermittelt den eigenen Funktionsnamen via GET DIAGNOSTICS ... PG_CONTEXT für aussagekräftige Fehler-Präfixe und einen Eingangs-RAISE NOTICE-Trace. Liegt direkt im äußeren BEGIN (kein Sub-Block). Der PG_CONTEXT-Kniff ist Komfort, kein Muss – wer ihn nicht braucht, lässt den Block weg oder setzt den Komponenten-Namen schlicht als Konstante.
  2. 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 via SELECT ... INTO (z. B. ein Default aus einer Konfigurations-Tabelle).
  3. Check parameter – Sub-Block mit allen Eingangs- und Vorbedingungs-Prüfungen am Anfang: Parameter-Validierung, Actor-Context, Permission-Prüfungen.
  4. 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$s referenziert 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 JOIN statt nacktem JOIN.
  • Positionelle Aliase T01T02T03 für jede aliasierte Tabellen-Referenz, pro Statement neu ab T01 durchnummeriert. 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. Gleichlange T0n halten die Spalten-Flucht.
  • ON auf eigener Zeile, unter dem INNER ausgerichtet. Eine einzelne Bedingung zwei Spaces eingerückt; mehrere Bedingungen als AND/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.sql und 003.sp_upd_project.sql haben 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:

  1. Co-Location: ls 007.* zeigt alle Objekte einer Tabelle auf einen Blick.
  2. Keine Renummerier-Steuer: Neue Procedure für project_member? Heißt 004.sp_neue_procedure.sql, fertig – keine 20 anderen Dateien müssen umbenannt werden.
  3. 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 OLDTG_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:

  1. 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.
  2. Don'ts als 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.
  3. 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ßlich deploy.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 T01T02 statt sprechender Namen. Begründung: unterschiedlich lange Aliase (pm/cp/sts) brechen die tabellarische Ausrichtung der Feldnamen; gleichlange T0n halten die Spalten-Flucht. Do: der ausgerichtete JOIN-Block. Don’t: der nackte JOIN mit pm/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 (pidaidnewrole) 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 -> Workload als 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 REPLACE allein ist trigger-safe.
  • Als .claude/rules/sql.md wird 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

Warum positionelle Aliase 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).

Ist die Block-Struktur nicht Overhead bei kleinen Prozeduren?

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.

Warum überhaupt von Hand formatieren – pgFormatter und sqlfluff machen das doch automatisch?

Auto-Formatter (pgFormattersqlfluff) 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.

Was, wenn Eingangsprüfung und Lookup verschränkt sind – etwa eine Permission-Prüfung, die erst ein zuvor geladenes 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.

Warum 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.

Gilt das auch für SQL Server / T-SQL?

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.

Worin unterscheidet sich eine Regeldatei für einen KI-Agenten von einem normalen Style-Guide?

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:

ETL-Kontext:

Datenqualität & Typ-Konvertierung:

/