Ein enum ist eine vom Schema definierte Liste fester Werte — perfekt für Status-Felder, Kategorien oder Berechtigungs-Stufen. PostgreSQL unterstützt das nativ, aber: Enums haben einige unangenehme Eigenschaften, die du kennen solltest, bevor du dich darauf festlegst. Oft ist eine Lookup-Tabelle mit text und Foreign Key flexibler.
Enum erstellen und verwenden
-- 1. Den Enum-Typ definieren (datenbankweit)
CREATE TYPE order_status AS ENUM (
'pending',
'paid',
'shipped',
'delivered',
'cancelled'
);
-- 2. In einer Tabelle verwenden
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
total numeric(10, 2) NOT NULL,
status order_status NOT NULL DEFAULT 'pending'
);Der Enum-Typ ist ein eigenständiges Datenbankobjekt — du definierst ihn einmal und kannst ihn in vielen Tabellen verwenden.
myapp=> INSERT INTO orders (total) VALUES (99.95) RETURNING id, status;
id | status
----+---------
1 | pending
myapp=> UPDATE orders SET status = 'paid' WHERE id = 1;
UPDATE 1
myapp=> UPDATE orders SET status = 'invalid_status' WHERE id = 1;
ERROR: invalid input value for enum order_status: "invalid_status"Postgres lehnt Werte ab, die nicht im Enum definiert sind — das ist die Hauptstärke gegenüber einem normalen text-Feld.
Sortierung und Vergleich
Enum-Werte werden in der Reihenfolge der Definition sortiert, nicht alphabetisch:
myapp=> SELECT status FROM orders ORDER BY status;
status
-----------
pending
paid
shipped
delivered
cancelledDas ist einer der nettesten Aspekte: bei Status-Lifecycles ist die Reihenfolge oft natürlich (pending vor paid vor shipped), und das spiegelt sich automatisch in ORDER BY wider.
Vergleiche <, > funktionieren entsprechend:
SELECT * FROM orders WHERE status >= 'paid';
-- Liefert: paid, shipped, delivered, cancelledWerte hinzufügen — und warum’s heikel ist
Neuen Wert ergänzen:
ALTER TYPE order_status ADD VALUE 'refunded';
-- Mit Position relativ zu existierenden Werten:
ALTER TYPE order_status ADD VALUE 'reviewing' BEFORE 'paid';
ALTER TYPE order_status ADD VALUE 'returned' AFTER 'delivered';Klingt einfach. Hat aber Einschränkungen:
ALTER TYPE ADD VALUEkann nicht in einer Transaktion mit anderen DDL-Statements kombiniert werden (außerBEFORE/AFTER-Varianten in PG 12+).- In Migrations-Tools wie Liquibase oder Flyway, die alles in eine Transaktion packen, ist das ein Stolperstein.
Beispiel-Fehler:
myapp=> BEGIN;
myapp=> ALTER TYPE order_status ADD VALUE 'refunded';
myapp=> -- Versuche, den neuen Wert zu nutzen:
myapp=> SELECT 'refunded'::order_status;
ERROR: unsafe use of new value "refunded" of enum type order_status
HINT: New enum values must be committed before they can be used.Lösung: ALTER TYPE … ADD VALUE separat committen, dann den neuen Wert verwenden.
Werte entfernen — geht NICHT direkt
Postgres bietet keinen direkten Weg, einen Wert aus einem Enum zu löschen:
myapp=> ALTER TYPE order_status DROP VALUE 'refunded';
ERROR: syntax error at or near "DROP"Das ist Absicht — wenn der Wert irgendwo in der Datenbank vorkommt, würde sein Verschwinden kaputte Zeilen hinterlassen. Stattdessen muss man:
- Alle Zeilen mit dem zu entfernenden Wert auf einen anderen umstellen (
UPDATE). - Einen neuen Enum-Typ ohne den Wert erstellen.
- Alle Spalten auf den neuen Typ casten.
- Den alten Typ droppen.
BEGIN;
-- 1. Bestehende Zeilen umsetzen
UPDATE orders SET status = 'cancelled' WHERE status = 'refunded';
-- 2. Neuen Typ ohne 'refunded'
CREATE TYPE order_status_new AS ENUM (
'pending', 'paid', 'shipped', 'delivered', 'cancelled'
);
-- 3. Spalten casten
ALTER TABLE orders
ALTER COLUMN status TYPE order_status_new
USING status::text::order_status_new;
-- 4. Default neu setzen, alten Typ droppen, neuen umbenennen
ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'pending';
DROP TYPE order_status;
ALTER TYPE order_status_new RENAME TO order_status;
COMMIT;Das ist eine ausgewachsene Migration — und der Hauptgrund, warum viele Teams ab einer gewissen Tabellengrößer von Enum auf Lookup-Tabelle umsteigen.
Werte umbenennen
Glücklicherweise einfach:
ALTER TYPE order_status RENAME VALUE 'shipped' TO 'in_transit';Existierende Zeilen, die shipped enthielten, zeigen jetzt in_transit — automatisch, ohne dass die Zeilen umgeschrieben werden müssen. Postgres speichert intern eine OID pro Enum-Wert; nur das Label ändert sich.
Alternative: Lookup-Tabelle
CREATE TABLE order_statuses (
code text PRIMARY KEY,
label_de text NOT NULL,
label_en text NOT NULL,
sort_order integer NOT NULL,
is_terminal boolean NOT NULL DEFAULT FALSE
);
INSERT INTO order_statuses (code, label_de, label_en, sort_order, is_terminal) VALUES
('pending', 'Ausstehend', 'Pending', 10, FALSE),
('paid', 'Bezahlt', 'Paid', 20, FALSE),
('shipped', 'Versandt', 'Shipped', 30, FALSE),
('delivered', 'Geliefert', 'Delivered', 40, TRUE),
('cancelled', 'Storniert', 'Cancelled', 99, TRUE);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
total numeric(10, 2) NOT NULL,
status text NOT NULL REFERENCES order_statuses(code) DEFAULT 'pending'
);Vorteile:
- Werte hinzufügen/entfernen über normale
INSERT/DELETE— keine DDL-Akrobatik. - Zusätzliche Metadaten (Übersetzungen, Sortierung, Status-Klassen) direkt mit am Wert.
- Foreign Key garantiert Konsistenz.
Nachteile:
- Joins für die Übersetzung (
SELECT o.*, s.label_de FROM orders o JOIN order_statuses s ON …). - Etwas mehr Speicher (text-Werte statt 4-Byte-OIDs).
In der Praxis: für wenige (3-5), stabile Werte ist Enum okay. Für viele Werte oder häufige Änderungen ist die Lookup-Tabelle die bessere Wahl.
Enum inspizieren
-- Alle Werte eines Enums in Reihenfolge
myapp=> SELECT enum_range(NULL::order_status);
enum_range
---------------------------
{pending,paid,shipped,delivered,cancelled}
-- Erster und letzter Wert
myapp=> SELECT enum_first(NULL::order_status), enum_last(NULL::order_status);
enum_first | enum_last
------------+-----------
pending | cancelled
-- Alle Enums in der Datenbank
myapp=> \dT+ *\dT (mit + für Details) zeigt alle benutzerdefinierten Typen, inklusive Enums.
Häufige Stolperfallen
ALTER TYPE ADD VALUE in einer Transaktion ist tückisch.
Innerhalb einer Transaktion kann der neu hinzugefügte Wert NICHT verwendet werden — Postgres weigert sich. Migrations-Tools, die alles in eine Transaktion packen, müssen ALTER TYPE ADD VALUE in einem separaten Schritt vor der eigentlichen Migration ausführen. Workaround: zwei Migrations-Files, eines für ADD VALUE, das nächste für die Verwendung.
Werte entfernen erfordert kompletten Typ-Tausch.
Es gibt kein ALTER TYPE … DROP VALUE. Der einzige Weg ist neuer Typ + Spalten umkasten + alten Typ droppen — eine teure Operation bei großen Tabellen, weil ALTER COLUMN TYPE die Spalte rewrited (außer in seltenen kompatiblen Fällen). Lookup-Tabelle vermeidet das komplett.
Enums in mehreren Schemas duplizieren oft.
Wenn du ein Multi-Tenant-Setup mit Schema-pro-Tenant hast, brauchst du den Enum-Typ in jedem Schema. Lookup-Tabellen leben in einem zentralen Schema und werden referenziert. Enum: Pflege-Mehraufwand pro Tenant.
Sortier-Reihenfolge ist die Definition-Reihenfolge.
Praktisch für Lifecycle-Status (pending < paid < shipped), aber unflexibel: wenn du später einen Status „pre_paid” zwischen pending und paid einfügen willst, kannst du das mit ADD VALUE BEFORE 'paid' lösen — aber bei komplexen Reorderings oft schmerzhaft.
Cast zu text ist deine Brücke.
Wenn du Werte aus einem Enum in einem anderen Kontext brauchst (z. B. Vergleich mit text), gibt’s 'status'::text-Cast. Ebenso umgekehrt: 'paid'::order_status. Das ist auch der Weg, beim Migrieren zwischen alten und neuen Enum-Typen.
Enums in JSON-APIs als String serialisieren.
Treiber liefern Enum-Werte als String — wie text. JavaScript/Python sehen das einfach als String mit fester Wertemenge. Wer in TypeScript Type-Safety will: einen TS-Enum aus den DB-Werten generieren (mit Tools wie pg-enum-types).
Weiterführende Ressourcen
Externe Quellen
- Enumerated Types – PostgreSQL Documentation
- CREATE TYPE – PostgreSQL Documentation
- ALTER TYPE – PostgreSQL Documentation
- Enum Support Functions