Eine Tabelle ohne automatisch generierte ID ist die Ausnahme — fast jede Web-App will einen bigserial-Primary-Key. PostgreSQL bietet dafür zwei Mechanismen: das altbewährte SERIAL und das modernere GENERATED AS IDENTITY (seit PG 10). Beide funktionieren, aber IDENTITY ist der saubere Weg für neue Tabellen — hier die Unterschiede, mit Beispielen.
SERIAL — die alte Schreibweise
CREATE TABLE users (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE
);bigserial ist kein eigener Typ, sondern eine Kurzschreibweise. Postgres expandiert das intern zu:
CREATE SEQUENCE users_id_seq;
CREATE TABLE users (
id bigint NOT NULL DEFAULT nextval('users_id_seq'),
email text NOT NULL UNIQUE
);
ALTER SEQUENCE users_id_seq OWNED BY users.id;Drei separate Objekte: die Tabelle, die Sequenz und die Owner-Verknüpfung. Inserts ohne id rufen nextval() auf der Sequenz auf — das ist der Auto-Increment.
Drei Größen-Varianten:
| Schreibweise | Spalten-Typ | Wertebereich |
|---|---|---|
smallserial | smallint | bis 32 767 |
serial | integer | bis ~2,1 Mrd. |
bigserial | bigint | bis ~9,2 · 10¹⁸ |
IDENTITY — der moderne, SQL-Standard-Weg
Seit PostgreSQL 10:
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE
);GENERATED ALWAYS AS IDENTITY ist sauberer — es ist Teil des SQL-Standards (DB2, Oracle, SQL Server kennen das ebenso).
Zwei Varianten:
| Klausel | Verhalten |
|---|---|
GENERATED ALWAYS AS IDENTITY | Postgres vergibt die ID immer automatisch. Ein expliziter Insert mit ID schlägt fehl. |
GENERATED BY DEFAULT AS IDENTITY | Postgres vergibt die ID, es sei denn der Insert übergibt selbst eine ID. |
myapp=> INSERT INTO users (id, email) VALUES (42, 'alice@example.com');
ERROR: cannot insert a non-DEFAULT value into column "id"
DETAIL: Column "id" is an identity column defined as GENERATED ALWAYS.
HINT: Use OVERRIDING SYSTEM VALUE to override.
-- Wer das wirklich will (z. B. Migration aus altem System):
myapp=> INSERT INTO users (id, email) OVERRIDING SYSTEM VALUE
VALUES (42, 'alice@example.com');
INSERT 0 1Dieser Schutz ist der größte praktische Unterschied: BY DEFAULT ist permissiv (wie SERIAL), ALWAYS zwingt zur sauberen Disziplin.
Vergleich auf einen Blick
| Aspekt | SERIAL | GENERATED AS IDENTITY |
|---|---|---|
| SQL-Standard | Nein (Postgres-Erweiterung) | Ja |
| Verfügbar seit | schon immer | PG 10 |
| Schutz vor manuellem Insert | Nein | Ja (mit ALWAYS) |
| Sequenz-Lifecycle | An die Spalte gebunden über OWNED BY | Inhärent Teil der Tabelle |
In pg_dump | Sequenz separat sichtbar | Eingebettet in Tabelle |
| Migrations-Tools | überall unterstützt | seit ~2018 überall |
| Lesbarkeit | knapp, aber unklar | länger, aber explizit |
Empfehlung für neue Projekte: bigint GENERATED ALWAYS AS IDENTITY.
Bestehende Projekte mit bigserial: kein zwingender Migrationsgrund. Funktioniert weiter, lange Jahre Wartung gibt’s für beide.
Sequenzen direkt verwalten
Beide Varianten erzeugen unter der Haube eine Sequenz — ein eigenes Postgres-Objekt mit eigenen Operationen:
-- Aktuelle Position abfragen (in der eigenen Session):
SELECT currval('users_id_seq');
-- Nächsten Wert holen (verbraucht ihn):
SELECT nextval('users_id_seq');
-- Letzten Wert ohne Verbrauch sehen:
SELECT last_value FROM users_id_seq;
-- Sequenz manuell auf einen Wert setzen
-- (z. B. nach Migration: ID auf den höchsten existierenden Wert):
SELECT setval('users_id_seq', (SELECT max(id) FROM users));setval ist besonders wichtig nach Imports oder Daten-Restores: wenn Zeilen mit OVERRIDING SYSTEM VALUE mit IDs eingefügt wurden, läuft die Sequenz hinterher und nächste Inserts kollidieren — setval synchronisiert sie wieder.
Lücken in IDs sind normal
Beide Varianten produzieren keine lückenlose Folge:
myapp=> BEGIN;
BEGIN
myapp=> INSERT INTO users (email) VALUES ('alice@example.com') RETURNING id;
id
----
1
myapp=> ROLLBACK;
ROLLBACK
myapp=> INSERT INTO users (email) VALUES ('bob@example.com') RETURNING id;
id
----
2Die id = 1 ist verbraucht, obwohl die Zeile nie committed wurde. Auch parallele Sessions, Server-Restarts und ON CONFLICT DO NOTHING produzieren Lücken. Wer sich auf lückenlose Nummern verlässt (z. B. Rechnungs-Nummern aus rechtlichen Gründen), braucht ein anderes Pattern — etwa eine separate Counter-Tabelle mit einem UPDATE … RETURNING-Lock.
Migration: SERIAL → IDENTITY
Falls du eine bestehende SERIAL-Tabelle umstellen willst:
BEGIN;
-- 1. SERIAL-Default entfernen (löst Verknüpfung zur Sequenz)
ALTER TABLE users ALTER COLUMN id DROP DEFAULT;
-- 2. Sequenz an die Spalte als IDENTITY binden
ALTER TABLE users ALTER COLUMN id ADD GENERATED ALWAYS AS IDENTITY;
-- 3. Synchronisieren mit dem aktuellen Maximum
SELECT setval(pg_get_serial_sequence('users', 'id'),
(SELECT max(id) FROM users));
COMMIT;Die alte Sequenz bleibt bestehen und wird weiterverwendet — nur das Verhalten beim Insert ändert sich.
Häufige Stolperfallen
serial ist ein Trick, kein eigener Typ.
\d users zeigt die Spalte als bigint mit Default nextval(...) — nicht als bigserial. Wer in Migrations-Tools darauf prüft, was eine Spalte ist, sucht oft vergeblich nach „SERIAL”. Die Sequenz ist ein eigenes Objekt, das mit \ds aufgelistet wird.
OVERRIDING SYSTEM VALUE ist der Notausgang.
Bei GENERATED ALWAYS musst du OVERRIDING SYSTEM VALUE schreiben, wenn du wirklich eine eigene ID einfügen willst (z. B. beim Restore aus einem Dump). Anschließend setval() aufrufen, sonst vergibt Postgres beim nächsten regulären Insert eine ID, die schon existiert — UNIQUE-Verletzung folgt.
DROP TABLE droppt die Sequenz nur, wenn OWNED BY gesetzt ist.
Bei SERIAL setzt Postgres OWNED BY automatisch — Sequenz wird mit der Tabelle gedroppt. Wer manuell eine Sequenz erzeugt und ohne OWNED BY nutzt, hinterlässt nach DROP TABLE verwaiste Sequenzen. IDENTITY macht das automatisch richtig.
Lücken sind kein Bug, sondern Design.
Die Sequenz cached neue Werte in Blöcken (Default: 1). Bei Restart oder Crash können diese ungenutzt verloren gehen. Wer das vermeiden will: CREATE SEQUENCE … CACHE 1 (Default), aber bei vielen parallelen Inserts mit Performance-Einbuße. Lückenlose Nummern: separate Counter-Tabelle mit Locks.
UUID statt SERIAL für Public-IDs.
Wenn die ID an Endkunden ausgeliefert wird (URLs, APIs), verrät eine fortlaufende bigserial die Nutzerzahl. UUIDs sind dafür besser geeignet. Gemischte Setups: interne id als bigint IDENTITY, externe public_id als uuid mit gen_random_uuid() als Default.
In MySQL-Migrationen taucht oft AUTO_INCREMENT auf — Postgres versteht das nicht.
Wer Schemas aus MySQL portiert, muss AUTO_INCREMENT in GENERATED AS IDENTITY (oder serial) übersetzen. Tools wie pgloader machen das automatisch, manuell ist’s eine schnelle Suche-und-Ersetzen-Aktion.
Weiterführende Ressourcen
Externe Quellen
- Numeric Types: Serial Types
- CREATE TABLE: GENERATED AS IDENTITY
- CREATE SEQUENCE
- Sequence Manipulation Functions
- Don’t Do This: Don’t use serial – PostgreSQL Wiki