Postgres-Tabellen-Konventionen — Naming, Keys und Audit-Spalten für ein konsistentes Schema

Eine Tabelle anzulegen dauert dreißig Sekunden – sich über ihren Namen, ihren Schlüssel und ihre Spaltentypen zu ärgern, dann zwei Jahre. Postgres-Tabellen-Konventionen nehmen diesen Ärger einmal vorne weg: konsistente Namen, ein vorhersehbarer Primärschlüssel, Datentypen ohne Überraschung und Audit-Spalten, die jede Zeile erklären. Ohne sie driftet ein Schema mit jeder neuen Tabelle weiter auseinander – und beim ersten Join, beim ersten Audit, beim ersten Schema-Diff rächt sich das.

Dieser Artikel destilliert einen erprobten Satz Postgres-Tabellen-Konventionen aus einem produktiven Framework: wie Tabellen heißen, welchen Primärschlüssel sie tragen, welche Datentypen gelten und wie Constraints, Kommentare und Seed-Daten sauber abgelegt werden. Wichtig vorweg: Das sind die Konventionen eines Frameworks, kein offizieller Postgres-Standard. Wo eine Festlegung Stilentscheidung ist und kein technisches Muss, steht es dabei.

Das nimmst du mit:

  • Naming: snake_case, Singular, und warum die meisten Tabellen einen Surrogat-Schlüssel id mit benanntem CONSTRAINT pk_<tabelle> bekommen – natürliche Schlüssel werden UNIQUE, nicht der PK;
  • Datentypen: varchar statt text (eine bewusste Stilentscheidung, kein Naturgesetz – Begründung unten) und Audit-Spalten mit _on-Suffix;
  • Constraints sauber abgelegt: FOREIGN KEY und UNIQUE als separates, idempotentes (wiederholbar ausführbares) ALTER TABLE statt inline – eine Deployment-Entscheidung, kein Muss, mit benannten Constraints und Index-Konvention;
  • COMMENT ON TABLE/COLUMN und Seed-INSERT mit Leading-Komma;
  • und – am Ende – wie diese Konventionen überhaupt entstanden sind: aus manuellen Korrekturen, die ein KI-Agent in Regeln überführt.

Voraussetzung: Postgres (Beispiele auf 14+), Grundkenntnisse DDL (CREATE TABLEALTER TABLE, Constraints).

Inhalt

Naming und Keys

Drei Regeln tragen das ganze Naming – sie beantworten „wie heißt es?“ und „woran erkenne ich eine Zeile?“, bevor man die Tabelle öffnet:

  1: CREATE TABLE IF NOT EXISTS app.project
  2: (
  3:     id          bigserial     NOT NULL
  4:    ,slug        varchar(100)  NOT NULL
  5:    ,name        varchar(200)  NOT NULL
  6:    ,parent_id   bigint            NULL
  7: 
  8:    ,CONSTRAINT pk_project        PRIMARY KEY (id)
  9:    ,CONSTRAINT chk_project_name  CHECK (length(trim(name)) > 0)
 10: );

  • snake_case und Singular. Die Tabelle heißt project, nicht Projects und nicht projects. Spalten folgen demselben Stil, Fremdschlüssel-Spalten ergeben sich natürlich: parent_id, nicht parentId oder parents_id.
  • Surrogat-Schlüssel id. Die meisten Tabellen bekommen eine künstliche id-Spalte als Primärschlüssel. Die Ausnahme sind reine Verknüpfungs-Tabellen: Eine user_role mit PRIMARY KEY (user_id, role_id) braucht keine zusätzliche id – der zusammengesetzte natürliche Schlüssel ist dort der PK. id bigserial ist hier die Schreibweise aus Kürze; gleichwertig und SQL-Standard-näher ist id bigint GENERATED ALWAYS AS IDENTITY (siehe FAQ).
  • Benannter Primärschlüssel-Constraint. Der PK steht als eigener CONSTRAINT pk_<tabelle> am Ende des Blocks, nicht inline als id bigserial PRIMARY KEY. So erscheint er in Fehlermeldungen und \d-Ausgaben mit sprechendem Namen statt als generiertes project_pkey. Inline im CREATE TABLE bleiben nur PRIMARY KEY und CHECK (siehe chk_project_name oben).
  • Natürliche Schlüssel werden UNIQUE, nicht der PK. Der slug identifiziert ein Projekt fachlich eindeutig – aber er wird ein UNIQUE-Constraint (siehe unten), nicht der Primärschlüssel. Der PK bleibt die fachlich bedeutungslose id; ein fachlicher Wert kann sich ändern, ein Surrogat-Schlüssel ist typischerweise stabil (man ändert ihn schlicht nicht).

Die Tabellarik im Block – Spaltenname, Typ und Nullability untereinander ausgerichtet – ist kein Selbstzweck: Sie macht aus der Spaltenliste eine Tabelle, die man auf einen Blick scannt. Dasselbe Prinzip gilt für Prozeduren-Signaturen und JOINs; im Detail hergeleitet in der PL/pgSQL-Fallstudie.

Datentypen und Audit-Spalten

Zwei Festlegungen, die jede Tabelle betreffen: der String-Typ und die Audit-Spalten.

  1:     is_active    boolean       NOT NULL DEFAULT true
  2:    ,created_on   timestamptz   NOT NULL DEFAULT now()
  3:    ,created_by   varchar(100)  NOT NULL
  4:    ,modified_on  timestamptz       NULL
  5:    ,modified_by  varchar(100)      NULL

varchar statt text. Zeichenspalten werden varchar – mit Länge, wo eine fachliche Grenze existiert (varchar(100)), ohne Länge, wo nicht. Wichtig zur Einordnung: In Postgres ist varchar ohne Länge intern identisch zu text (gleiche Speicherung, gleiche Performance); varchar(n) fügt nur eine Längenprüfung hinzu. varchar ist hier also persönliche Präferenz, kein Performance-Argument – wer durchgängig text nimmt, macht nichts falsch. Nur mischen sollte man die beiden nicht.

Audit-Spalten mit _on-Suffix. Tabellen, deren Zeilen sich über die Zeit ändern, tragen vier Spalten: created_on / created_by und modified_on / modified_by. (Reine Lookup- und Verknüpfungs-Tabellen oder bewusst unveränderliche Tabellen – etwa im Event-Sourcing – brauchen sie nicht.) Zwei Konventionen dahinter:

  • Suffix _on, nie _at für Zeitstempel: created_onmodified_on. Eine Form, durchgängig – das macht die Spalten über alle Tabellen hinweg greppbar.
  • created_* wird beim Insert gesetzt (created_on per DEFAULT now()created_by vom anlegenden Akteur), modified_* bleibt beim Insert NULL und wird erst bei jedem UPDATE gefüllt – von der Anwendung oder zentral durch einen BEFORE UPDATE-Trigger. Dass modified_on bei einer frisch angelegten Zeile NULL ist, ist gewollt: „noch nie geändert“ und „heute geändert“ bleiben unterscheidbar.

Constraints, Comments und Seed

In dieser Konvention stehen UNIQUE– und FOREIGN KEY-Constraints nicht im CREATE TABLE, sondern als separate ALTER TABLE-Statements nach dem OWNER. Das ist eine bewusste Deployment-Entscheidung, keine Postgres-Vorgabe – viele Teams definieren Fremdschlüssel genauso gut inline, weil dann das ganze Datenmodell an einer Stelle steht. Der Grund für die getrennte Form hier sind Idempotenz und Reihenfolge. Idempotent heißt: Man kann das Skript beliebig oft hintereinander laufen lassen und bekommt immer denselben Endzustand – ein zweiter Durchlauf legt nichts doppelt an und bricht auch nicht mit „existiert bereits“ ab. Genau das braucht ein Deploy-Skript, das bei jedem Rollout neu durchläuft:

  1: ALTER TABLE app.project OWNER TO app_owner;
  2: 
  3: -- Unique constraints
  4: ALTER TABLE app.project DROP CONSTRAINT IF EXISTS uq_project_slug;
  5: ALTER TABLE app.project ADD  CONSTRAINT uq_project_slug UNIQUE (slug);
  6: 
  7: -- Foreign keys
  8: ALTER TABLE app.project DROP CONSTRAINT IF EXISTS fk_project_parent_id;
  9: ALTER TABLE app.project ADD  CONSTRAINT fk_project_parent_id FOREIGN KEY (parent_id) REFERENCES app.project(id) ON DELETE SET NULL;
 10: 
 11: -- Indexes
 12: CREATE INDEX IF NOT EXISTS idx_project_parent_id ON app.project (parent_id);

  • Benannt nach Schema uq_<tabelle>_<spalte>fk_<tabelle>_<spalte>idx_<tabelle>_<spalte>. Sprechende Namen statt postgres-generierter – sie erscheinen lesbar in jeder Verletzungs-Meldung und lassen sich gezielt droppen.
  • Reihenfolge: Die separaten ALTER-Statements laufen nach allen CREATE TABLE. Referenziert eine Tabelle eine andere, die im Deploy erst später kommt, scheitert ein inline-FK an der noch nicht existierenden Zieltabelle – die getrennte Form löst das (und zyklische Referenzen gleich mit).
  • Idempotenz über DROP CONSTRAINT IF EXISTS + ADD. Postgres kennt kein ADD CONSTRAINT IF NOT EXISTS; das Drop-davor-Pattern macht das Deploy-Skript wiederholbar. Vorsicht bei großen Tabellen: DROP + ADD validiert den FK neu bzw. baut den UNIQUE-Index komplett neu – mit Lock, Index-Neuerstellung und FK-Vollvalidierung. Bei Tabellen mit vielen Millionen oder Milliarden Zeilen kann das einen Deploy ausbremsen oder blockieren; dort legt man Constraints einmalig an, statt sie bei jedem Lauf neu zu ziehen (ggf. ADD … NOT VALID und ein separates VALIDATE CONSTRAINT außerhalb des kritischen Fensters).
  • Indizes folgen demselben Namens-Schema und sind idempotent über IF NOT EXISTS. Der UNIQUE-Constraint bringt seinen Index automatisch mit; separate Indizes sind für häufig gefilterte Nicht-Unique-Spalten gedacht – allen voran Fremdschlüssel-Spalten wie parent_id.
  • ON DELETE bewusst wählen: SET NULL für optionale Referenzen, CASCADE für abhängige Detail-Zeilen, sonst der Default (Restrict).

Kommentare sind in dieser Konvention Pflicht – andere Teams dokumentieren stattdessen im Data-Catalog oder ER-Modell, beides legitim. Jede Tabelle bekommt einen COMMENT ON TABLE; Spalten mit nicht-offensichtlicher Bedeutung einen COMMENT ON COLUMN:

  1: COMMENT ON TABLE  app.project      IS 'Stammdaten: benannte Projekte.';
  2: COMMENT ON COLUMN app.project.slug IS 'Eindeutiger Projekt-Slug (Natural Key, UNIQUE).';

Surrogat-id und die vier Audit-Spalten brauchen keinen Kommentar – sie sind framework-weit einheitlich. Codes und Flags dagegen schon: bei einer status-Spalte gehören die zulässigen Werte in den Kommentar.

Seed-Daten schließlich folgen demselben Leading-Komma-Stil; ON CONFLICT macht den Seed wiederholbar:

  1: INSERT INTO app.project (slug, name, created_by)
  2: VALUES
  3:     ('alpha', 'Alpha', '<system>')
  4:    ,('beta',  'Beta',  '<system>')
  5: ON CONFLICT (slug) DO UPDATE
  6: SET
  7:     name        = EXCLUDED.name
  8:    ,modified_on = now();

So ist diese Konvention entstanden

Keine dieser Regeln ist vom Himmel gefallen. Sie sind aus der Praxis abgeleitet – aus dem Muster, das übrig bleibt, wenn man genug von Hand korrigiert hat. Genau dafür gibt es einen reproduzierbaren Workflow: Ein KI-Agent generiert eine Tabelle, man bringt sie einmal von Hand in Form (Naming, benannter PK, separates ALTER für die Constraints), und der Agent leitet aus der Differenz die Regel ab und legt sie in eine .claude/rules/-Datei. Ab dann generiert er Tabellen schon nach deinen Konventionen.

Wie dieser Generate→Refine→Derive-Loop im Detail funktioniert, zeigt SQL-Konventionen mit Claude Code ableiten. Den Überblick über das Zusammenspiel von Rules, Skills und Agenten liefert der Hub KI-gestützte SQL-Entwicklung mit Claude Code.

Zum Mitnehmen: Diese Tabellen-Konventionen gibt es als generalisierte Starter-Regeldatei zum Download – leg sie als .claude/rules/tables.md in dein Repo, und dein KI-Agent legt Tabellen ab der nächsten Session konventionskonform an.

Zusammenfassung

Eine Tabelle ohne Konvention ist für sich lesbar – im Maßstab von fünfzig Tabellen wird die Stil-Vielfalt zum Such-, Review- und Migrations-Problem. Die hier gezeigten Postgres-Tabellen-Konventionen adressieren genau das – als bewusste Festlegung, nicht als Postgres-Gesetz.

Take-Away:

  • Naming: snake_case + Singular; Surrogat-id mit benanntem CONSTRAINT pk_<tabelle> (nie inline) für die meisten Tabellen – Ausnahme reine Verknüpfungs-Tabellen mit Composite-PK; natürliche Schlüssel werden UNIQUE.
  • Datentypen: varchar statt text (persönliche Präferenz, kein Performance-Argument); Audit-Spalten created_*/modified_* mit _on-Suffix, modified_* erst beim UPDATE.
  • Constraints: FOREIGN KEY/UNIQUE als separates, benanntes, idempotentes ALTER TABLE (Deployment-Entscheidung, kein Muss) – bei großen Tabellen die DROP/ADD-Kosten beachten. Indizes als idx_<tabelle>_<spalte>.
  • Dokumentation: COMMENT ON TABLE/COLUMN in dieser Konvention Pflicht; Seed mit Leading-Komma und ON CONFLICT.
  • Konsequenz schlägt die Wahl der Form: Ob varchar oder textbigserial oder IDENTITY, Singular oder Plural – der Wert entsteht erst, wenn jede Tabelle dieselbe Entscheidung trägt.

FAQ

bigserial oder GENERATED ALWAYS AS IDENTITY?

Beides erzeugt einen auto-inkrementierenden Surrogat-Schlüssel. GENERATED ALWAYS AS IDENTITY ist die jüngere, SQL-Standard-konforme Variante und schützt zusätzlich davor, dass man die id versehentlich von Hand setzt – für neue Projekte die empfehlenswertere Wahl. bigserial ist die ältere, kompakte Schreibweise (intern eine sequence plus DEFAULT nextval(...)); die Konvention hier nutzt sie aus Kürze und Bestandskompatibilität. Beide sind technisch in Ordnung, solange es überall dieselbe Form ist – und immer mit benanntem CONSTRAINT pk_<tabelle>.

varchar oder text – ist das nicht egal?

Technisch fast. In Postgres ist varchar ohne Länge intern identisch zu text – gleiche Speicherung, gleiche Performance. varchar(n) fügt nur eine Längenprüfung hinzu. Es gibt also keinen Performance-Grund für die eine oder andere Form. Diese Konvention nimmt varchar, um die optionale Längen-Affordanz zu haben (varchar(100) dokumentiert eine fachliche Grenze) – ein Schema durchgängig auf text ist genauso gut.

Warum stehen FOREIGN KEYs hier nicht inline im CREATE TABLE?

Das ist eine Konventions-Entscheidung, keine allgemeine Regel – inline-FKs sind völlig verbreitet und haben den Vorteil, dass das Modell an einer Stelle steht. Diese Konvention trennt sie aus zwei Gründen: Idempotenz (ein separates DROP CONSTRAINT IF EXISTS … ADD CONSTRAINT … lässt sich beim Re-Deploy wiederholen) und Reihenfolge (die ALTER-Statements laufen nach allen CREATE TABLE, sodass auch vorwärts- und zyklische Referenzen aufgehen). Wer keine wiederholbaren Deploy-Skripte braucht, fährt mit inline-FKs einfacher.

Braucht wirklich jede Tabelle Audit-Spalten und einen Tabellen-Kommentar?

Nein. Audit-Spalten lohnen sich bei veränderlichen Geschäftsobjekten; reine Lookup-/Verknüpfungs-Tabellen oder unveränderliche (Event-Sourcing-)Tabellen kommen ohne modified_* aus. Und der Pflicht-Kommentar ist Team-Politik, kein technisches Muss – wer im Data-Catalog oder ER-Modell dokumentiert, kann ihn weglassen. Die Konvention setzt beides als Default, weil der Grenzfall „lieber zu viel Doku als zu wenig“ im Zweifel günstiger ist.

Verwandte Artikel

KI-Workflow:

SQL-Konventionen & Struktur: