Drei Befehle decken alle einfachen Schreibvorgänge ab: INSERT legt Zeilen an, UPDATE ändert sie, DELETE entfernt sie. Sie sind kurz erklärt, haben aber jeweils Eigenheiten — vor allem die Postgres-spezifischen Erweiterungen INSERT … RETURNING und UPDATE … FROM ändern, wie produktiv man arbeiten kann.
INSERT — Zeilen anlegen
Die Grundform legt eine einzelne Zeile in einer Tabelle an. Wir geben an, in welche Spalten die Werte gehen und welche Werte das sind:
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice');Spalten, die nicht aufgezählt werden, bekommen ihren DEFAULT-Wert (oder NULL, wenn kein Default definiert ist). Hier wird also z. B. die id automatisch über die Sequenz generiert und created_at per Default auf now() gesetzt — vorausgesetzt, das Schema ist so eingerichtet.
Mehrere Zeilen auf einmal:
INSERT INTO users (email, name) VALUES
('alice@example.com', 'Alice'),
('bob@example.com', 'Bob'),
('carol@example.com', 'Carol');Multi-Row-Insert ist deutlich schneller als einzelne INSERT-Statements (eine Round-Trip-Latenz, ein WAL-Flush pro Transaktion). Bei vielen tausend Zeilen lohnt sich COPY (siehe eigener Artikel).
INSERT INTO orders (customer_id, total, created_at)
VALUES (1, 99.95, DEFAULT);Ohne Spaltenliste: dann müssen alle Spalten in der angegebenen Reihenfolge ausgefüllt sein:
INSERT INTO orders VALUES (DEFAULT, 1, 99.95, DEFAULT);Spaltenliste-frei wird das fragil — eine neue Spalte in der Tabelle bricht alle solchen INSERTs. Faustregel: immer mit Spaltenliste schreiben.
INSERT aus einer Subquery:
INSERT INTO archive_orders (id, customer_id, total)
SELECT id, customer_id, total
FROM orders
WHERE created_at < '2025-01-01';Praktisch zum Migrieren oder Archivieren großer Datenmengen.
RETURNING — Werte direkt zurück
Postgres-Erweiterung, in INSERT/UPDATE/DELETE verfügbar:
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
RETURNING id, created_at;Spart einen separaten SELECT — gerade beim Zurücklesen von serial/identity-Werten oder Default-Spalten unverzichtbar. Eigener Artikel: RETURNING-Klausel.
UPDATE — Zeilen ändern
Mit UPDATE änderst du bestehende Zeilen. SET … legt fest, welche Spalten neue Werte bekommen, WHERE … schränkt ein, welche Zeilen davon betroffen sind:
UPDATE orders
SET status = 'shipped',
shipped_at = now()
WHERE id = 42;Hier ändern wir die Bestellung mit id = 42: ihr Status wird auf 'shipped' gesetzt, das Versanddatum auf den aktuellen Zeitpunkt. Mehrere Zuweisungen werden durch Komma getrennt.
Wichtig: ohne WHERE ändert das Statement alle Zeilen der Tabelle. Postgres warnt nicht — es führt aus. Wer in psql tippt, schützt sich mit dem Setting:
myapp=# \set ON_ERROR_ROLLBACK on
myapp=# \set ECHO queries…und vor allem: ad-hoc-Updates immer in einer Transaktion (BEGIN; UPDATE …; SELECT * FROM …; -- prüfen; COMMIT;).
UPDATE mit FROM (Postgres-Erweiterung)
UPDATE orders o
SET tax_rate = r.rate
FROM tax_rates r
WHERE o.country = r.country;Standard-SQL würde dafür eine korrelierte Subquery erfordern — Postgres erlaubt die FROM-Klausel direkt. Lesbarer und in den meisten Fällen schneller.
DELETE — Zeilen entfernen
DELETE löscht Zeilen aus einer Tabelle. Die WHERE-Klausel sagt, welche:
DELETE FROM orders
WHERE status = 'cancelled'
AND created_at < '2025-01-01';Hier werden Bestellungen entfernt, die sowohl storniert sind als auch vor dem 1. Januar 2025 angelegt wurden. Beide Bedingungen müssen wahr sein (AND). Auch hier gilt: ohne WHERE werden alle Zeilen entfernt — TRUNCATE ist dabei oft schneller (siehe unten).
DELETE mit USING
Analog zu UPDATE … FROM:
DELETE FROM orders o
USING blacklist b
WHERE o.customer_id = b.customer_id;Löscht alle Bestellungen, deren Kunde auf der Blacklist steht.
TRUNCATE — alles weg, schnell
TRUNCATE TABLE temp_imports;
TRUNCATE TABLE orders, order_items RESTART IDENTITY CASCADE;Unterschied zu DELETE:
TRUNCATEist deutlich schneller — kein Row-by-Row-Logging, kein WAL pro Zeile (außer beiwal_level=logical).- Trigger werden nicht ausgelöst (Standard).
RESTART IDENTITYsetztserial/identity-Sequenzen zurück.CASCADElöscht in referenzierenden Tabellen mit.TRUNCATEist transaktional — kann zurückgerollt werden.
Wie viele Zeilen wurden betroffen?
Postgres meldet pro Statement die Anzahl betroffener Zeilen:
myapp=# DELETE FROM orders WHERE status = 'cancelled';
DELETE 17In Anwendungen lesbar über die Treiber-API (rowCount, affected_rows, etc.). Mit RETURNING * bekommst du sogar die kompletten Zeilen zurück — praktisch für Audit-Logs.
Praxis-Beispiele
Vollständige psql-Session: Order anlegen, ändern, löschen
myapp=> BEGIN;
BEGIN
myapp=> INSERT INTO orders (customer_id, total, status)
VALUES (1, 99.95, 'pending')
RETURNING id, created_at;
id | created_at
----+------------------------
42 | 2026-05-06 11:32:17+00
(1 row)
myapp=> UPDATE orders
SET status = 'paid', paid_at = now()
WHERE id = 42
RETURNING status, paid_at;
status | paid_at
--------+------------------------
paid | 2026-05-06 11:32:45+00
(1 row)
myapp=> DELETE FROM orders
WHERE id = 42 AND status = 'cancelled';
DELETE 0
myapp=> COMMIT;
COMMITDELETE 0 zeigt: keine Zeile passte zur Bedingung. Genau das Verhalten, das man von einem Soft-Filter erwartet — kein Fehler, einfach kein Treffer.
UPDATE mit FROM für Bulk-Korrektur
UPDATE orders o
SET tax_rate = r.rate,
tax_amount = o.total * r.rate
FROM tax_rates r
WHERE o.country = r.country
AND o.status = 'pending'
AND o.tax_rate IS NULL;In einem Statement: alle pending-Bestellungen ohne berechnete Steuer bekommen den Satz aus der tax_rates-Tabelle und den entsprechenden Betrag. Ohne UPDATE … FROM müsste man das mit korrelierten Subqueries pro Spalte schreiben — deutlich umständlicher.
Atomares Archivieren mit DELETE … RETURNING
WITH archived AS (
DELETE FROM orders
WHERE created_at < '2025-01-01'
AND status IN ('delivered', 'cancelled')
RETURNING *
)
INSERT INTO archive_orders
SELECT * FROM archived;Beide Schritte (Delete + Insert ins Archiv) in einer Transaktion und einem Statement. Wenn der Insert fehlschlägt, läuft auch der Delete zurück — atomar.
Häufige Stolperfallen
UPDATE/DELETE ohne WHERE — keine Warnung.
Postgres führt einfach aus. Wer im psql interaktiv arbeitet, sollte sich angewöhnen: erst BEGIN;, dann das Statement, mit SELECT count(*) oder RETURNING prüfen, dann COMMIT; oder ROLLBACK;. Für reine Sicherheit hilft auch \set ON_ERROR_STOP on und ein zusätzlicher \echo-Hinweis vor riskanten Statements.
INSERT ohne Spaltenliste ist gefaehrlich.
INSERT INTO users VALUES (1, 'a@b.c', 'Alice') funktioniert heute. Wenn morgen eine Spalte phone zwischen email und name eingefügt wird, schreibt das Statement 'Alice' in die phone-Spalte und das echte name-Feld bleibt leer (oder das Statement scheitert mit Type-Mismatch). Spaltenliste IMMER explizit angeben.
UPDATE … FROM mit Mehrdeutigkeit liefert ein Random-Result.
Wenn die FROM-Tabelle pro Ziel-Zeile mehrere Treffer hat, nimmt Postgres irgendeinen — ohne Fehler, ohne Warnung. Beispiel: UPDATE orders SET tax_rate=r.rate FROM tax_rates r WHERE … und tax_rates enthält pro Land mehrere historische Sätze. Das Ergebnis ist nicht-deterministisch. Vor UPDATE … FROM prüfen, ob der Join eindeutig ist.
DELETE ist ein logisches Löschen — der Speicher bleibt zunaechst.
Postgres markiert Tupel beim DELETE als „tot”, räumt sie aber erst beim nächsten VACUUM weg. Bei großen Bulk-Deletes wächst die Tabelle nicht — der Speicher wird auch nicht sofort frei. Wer das braucht, ruft danach VACUUM/VACUUM FULL (Achtung: Lock!) oder nutzt TRUNCATE.
TRUNCATE feuert keine Trigger — DELETE schon.
BEFORE DELETE/AFTER DELETE-Trigger werden bei TRUNCATE standardmäßig NICHT ausgelöst. Das ist gewollt — würde sonst beim Bulk-Drop Performance ruinieren — aber überrascht, wenn man auf Trigger für Audit-Logs angewiesen ist. Workaround: TRUNCATE … ON COMMIT FIRE TRIGGERS oder eben DELETE nutzen.
UPDATE auf eine bereits gleiche Wert lohnt sich vermeiden.
UPDATE users SET email = 'a@b.c' WHERE id = 1 schreibt eine neue Zeile, auch wenn die E-Mail schon 'a@b.c' ist — Postgres hat keine „nur ändern, wenn anders”-Optimierung. Bei häufigen No-Op-Updates: vorher prüfen, oder WHERE … AND email IS DISTINCT FROM 'a@b.c'.
Weiterführende Ressourcen
Externe Quellen
- INSERT – PostgreSQL Documentation
- UPDATE – PostgreSQL Documentation
- DELETE – PostgreSQL Documentation
- TRUNCATE – PostgreSQL Documentation