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:

SQL
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:

SQL
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).

SQL DEFAULT-Wert explizit verwenden
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:

SQL
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:

SQL
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:

SQL
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:

SQL Bestellung als versendet markieren
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:

SQL
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)

SQL UPDATE basierend auf Werten aus anderer Tabelle
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:

SQL Alte stornierte Bestellungen aufraeumen
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:

SQL
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

SQL
TRUNCATE TABLE temp_imports;
TRUNCATE TABLE orders, order_items RESTART IDENTITY CASCADE;

Unterschied zu DELETE:

  • TRUNCATE ist deutlich schneller — kein Row-by-Row-Logging, kein WAL pro Zeile (außer bei wal_level=logical).
  • Trigger werden nicht ausgelöst (Standard).
  • RESTART IDENTITY setzt serial/identity-Sequenzen zurück.
  • CASCADE löscht in referenzierenden Tabellen mit.
  • TRUNCATE ist transaktional — kann zurückgerollt werden.

Wie viele Zeilen wurden betroffen?

Postgres meldet pro Statement die Anzahl betroffener Zeilen:

SQL
myapp=# DELETE FROM orders WHERE status = 'cancelled';
DELETE 17

In 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

SQL Komplett-Workflow
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;
COMMIT

DELETE 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

SQL Steuersaetze pro Land aktualisieren
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

SQL Alte Bestellungen ins Archiv verschieben
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

/ Weiter

Zurück zu SQL-Grundlagen

Zur Übersicht