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
idmit benanntemCONSTRAINT pk_<tabelle>bekommen – natürliche Schlüssel werdenUNIQUE, nicht der PK; - Datentypen:
varcharstatttext(eine bewusste Stilentscheidung, kein Naturgesetz – Begründung unten) und Audit-Spalten mit_on-Suffix; - Constraints sauber abgelegt:
FOREIGN KEYundUNIQUEals separates, idempotentes (wiederholbar ausführbares)ALTER TABLEstatt inline – eine Deployment-Entscheidung, kein Muss, mit benannten Constraints und Index-Konvention; COMMENT ON TABLE/COLUMNund Seed-INSERTmit 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 TABLE, ALTER TABLE, Constraints).
Inhalt
- Naming und Keys
- Datentypen und Audit-Spalten
- Constraints, Comments und Seed
- So ist diese Konvention entstanden
- Zusammenfassung
- FAQ
- Verwandte Artikel
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, nichtProjectsund nichtprojects. Spalten folgen demselben Stil, Fremdschlüssel-Spalten ergeben sich natürlich:parent_id, nichtparentIdoderparents_id. - Surrogat-Schlüssel
id. Die meisten Tabellen bekommen eine künstlicheid-Spalte als Primärschlüssel. Die Ausnahme sind reine Verknüpfungs-Tabellen: Eineuser_rolemitPRIMARY KEY (user_id, role_id)braucht keine zusätzlicheid– der zusammengesetzte natürliche Schlüssel ist dort der PK.id bigserialist hier die Schreibweise aus Kürze; gleichwertig und SQL-Standard-näher istid 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 alsid bigserial PRIMARY KEY. So erscheint er in Fehlermeldungen und\d-Ausgaben mit sprechendem Namen statt als generiertesproject_pkey. Inline imCREATE TABLEbleiben nurPRIMARY KEYundCHECK(siehechk_project_nameoben). - Natürliche Schlüssel werden
UNIQUE, nicht der PK. Derslugidentifiziert ein Projekt fachlich eindeutig – aber er wird einUNIQUE-Constraint (siehe unten), nicht der Primärschlüssel. Der PK bleibt die fachlich bedeutungsloseid; 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_atfür Zeitstempel:created_on,modified_on. Eine Form, durchgängig – das macht die Spalten über alle Tabellen hinweg greppbar. created_*wird beim Insert gesetzt (created_onperDEFAULT now(),created_byvom anlegenden Akteur),modified_*bleibt beim InsertNULLund wird erst bei jedemUPDATEgefüllt – von der Anwendung oder zentral durch einenBEFORE UPDATE-Trigger. Dassmodified_onbei einer frisch angelegten ZeileNULList, 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 allenCREATE 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 keinADD CONSTRAINT IF NOT EXISTS; das Drop-davor-Pattern macht das Deploy-Skript wiederholbar. Vorsicht bei großen Tabellen:DROP+ADDvalidiert den FK neu bzw. baut denUNIQUE-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 VALIDund ein separatesVALIDATE CONSTRAINTaußerhalb des kritischen Fensters). - Indizes folgen demselben Namens-Schema und sind idempotent über
IF NOT EXISTS. DerUNIQUE-Constraint bringt seinen Index automatisch mit; separate Indizes sind für häufig gefilterte Nicht-Unique-Spalten gedacht – allen voran Fremdschlüssel-Spalten wieparent_id. ON DELETEbewusst wählen:SET NULLfür optionale Referenzen,CASCADEfü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-
idmit benanntemCONSTRAINT pk_<tabelle>(nie inline) für die meisten Tabellen – Ausnahme reine Verknüpfungs-Tabellen mit Composite-PK; natürliche Schlüssel werdenUNIQUE. - Datentypen:
varcharstatttext(persönliche Präferenz, kein Performance-Argument); Audit-Spaltencreated_*/modified_*mit_on-Suffix,modified_*erst beimUPDATE. - Constraints:
FOREIGN KEY/UNIQUEals separates, benanntes, idempotentesALTER TABLE(Deployment-Entscheidung, kein Muss) – bei großen Tabellen die DROP/ADD-Kosten beachten. Indizes alsidx_<tabelle>_<spalte>. - Dokumentation:
COMMENT ON TABLE/COLUMNin dieser Konvention Pflicht; Seed mit Leading-Komma undON CONFLICT. - Konsequenz schlägt die Wahl der Form: Ob
varcharodertext,bigserialoderIDENTITY, 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.
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.
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:
- KI-gestützte SQL-Entwicklung mit Claude Code — Rules, Skills und Agenten, die Konventionen durchsetzen (Hub)
- SQL-Konventionen mit Claude Code ableiten — der Generate-Refine-Derive-Loop (Methodik)
SQL-Konventionen & Struktur: