RETURNING ist eine Postgres-Erweiterung, die in INSERT, UPDATE und DELETE funktioniert. Sie liefert die betroffenen Zeilen direkt zurück — ohne separaten SELECT. Das spart eine Round-Trip-Latenz und hält den Vorgang atomar: was du zurückbekommst, ist garantiert das, was geschrieben wurde.

INSERTRETURNING

Klassischer Use-Case: nach einem Insert die generierte ID und Default-Werte zurückbekommen — ohne ein zweites Statement abzusetzen. Ohne RETURNING wüsstest du nach dem Insert nicht, welche id der neue Datensatz bekommen hat (es sei denn, du fragst danach mit SELECT … WHERE … — was eine zusätzliche Round-Trip kostet).

SQL
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
RETURNING id, created_at;

Output:

SQL
 id |       created_at
----+------------------------
 42 | 2026-05-06 11:32:17+00

Die id = 42 wurde von der Sequenz vergeben, created_at vom Default now() gesetzt — beides bekommen wir direkt zurück. Mit RETURNING * kommen sogar alle Spalten der Zeile mit, inklusive von Triggers berechneter Werte.

Bei Multi-Row-Insert kommt eine ganze Result-Tabelle zurück:

SQL
INSERT INTO users (email, name) VALUES
    ('alice@example.com', 'Alice'),
    ('bob@example.com',   'Bob')
RETURNING id, email;

UPDATERETURNING

Praktisch, um zu wissen, was sich tatsächlich geändert hat — und welche Zeilen betroffen waren:

SQL
UPDATE orders
SET status = 'shipped',
    shipped_at = now()
WHERE id = 42
RETURNING id, status, shipped_at;

Postgres-Goodie: RETURNING sieht die neuen Werte der Zeile. Wer den alten Wert auch braucht, kann sich helfen mit ROW(t.*)::text oder einem CTE-Pattern (siehe Pitfalls).

Häufiges Audit-Pattern: die geänderten Zeilen direkt in eine History-Tabelle schreiben.

SQL UPDATE + Audit-Insert in einem Statement
WITH updated AS (
    UPDATE orders
    SET status = 'shipped'
    WHERE id = 42
    RETURNING id, status, shipped_at
)
INSERT INTO order_history (order_id, status, changed_at)
SELECT id, status, shipped_at FROM updated;

DELETERETURNING

Sehr nützlich für „löschen + behalten”-Patterns, etwa beim Verschieben in ein Archiv:

SQL
WITH deleted AS (
    DELETE FROM orders
    WHERE created_at < '2025-01-01'
    RETURNING *
)
INSERT INTO archive_orders SELECT * FROM deleted;

Beide Schritte (Delete + Archivierung) in einer Transaktion — entweder beides geht oder nichts. Ohne RETURNING müsste man die Zeilen erst kopieren, dann löschen — mit dem Risiko, dass zwischen Copy und Delete neue Zeilen reinkommen.

Berechnete Spalten in RETURNING

Du kannst beliebige Ausdrücke in RETURNING verwenden — nicht nur Spaltennamen:

SQL
INSERT INTO orders (customer_id, total)
VALUES (1, 99.95)
RETURNING id, total, total * 1.19 AS total_incl_tax;

Aliasse mit AS sind dabei erlaubt. Praktisch, wenn die Anwendung sowieso bestimmte Berechnungen braucht — eine Round-Trip-Ersparnis.

Mit ON CONFLICT kombiniert

INSERT … ON CONFLICT … DO UPDATE (UPSERT) liefert über RETURNING die finale Zeile zurück — egal ob neu eingefügt oder upgedated:

SQL
INSERT INTO users (email, name)
VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DO UPDATE
    SET name = EXCLUDED.name
RETURNING id, email, name,
          (xmax = 0) AS was_inserted;

Der kleine Trick mit xmax: bei einem Insert ist xmax = 0, bei einem UPDATE ist es eine Transaction-ID. Damit weißt du, ob die Zeile neu war oder upgedated wurde — ohne zweite Query.

In Anwendungen verwenden

Aus Sicht des Datenbank-Treibers (node-postgres, psycopg2, pgx, JDBC, …) ist ein INSERT … RETURNING einfach ein Statement, das ein Resultset liefert — kein Sonderfall, keine spezielle API. Du nutzt dieselbe query-/fetch-Methode wie für ein SELECT.

JavaScript node-postgres
const { rows } = await client.query(
  `INSERT INTO users (email, name)
   VALUES ($1, $2)
   RETURNING id, created_at`,
  ['alice@example.com', 'Alice']
);
const newUser = rows[0]; // { id: 42, created_at: ... }
Go database/sql
var id int64
var createdAt time.Time
err := db.QueryRow(
    `INSERT INTO users (email, name)
     VALUES ($1, $2)
     RETURNING id, created_at`,
    "alice@example.com", "Alice",
).Scan(&id, &createdAt)

Statt Exec (kein Resultset) wird QueryRow/Query verwendet, weil das Statement Zeilen zurückgibt.

Praxis-Beispiele

Vollständiger Audit-Trail mit RETURNING

SQL UPDATE plus History-Insert in einem Statement
WITH updated AS (
    UPDATE orders
    SET status = 'shipped',
        shipped_at = now()
    WHERE id = 42
    RETURNING id, status, shipped_at, customer_id
)
INSERT INTO order_events (order_id, event_type, payload, created_at)
SELECT id,
       'shipped',
       jsonb_build_object('customer_id', customer_id),
       shipped_at
FROM updated;

Der WITH-Block ist der “data-modifying CTE” — Postgres erlaubt INSERT/UPDATE/DELETE darin. Beide Statements laufen in einer Transaktion: entweder beide gehen oder keiner. Saubere Variante für Event-Sourcing- und Audit-Patterns.

Soft-Delete + Archivierung in einem Schritt

SQL
WITH deleted AS (
    UPDATE users
    SET deleted_at = now()
    WHERE last_login_at < now() - interval '2 years'
      AND deleted_at IS NULL
    RETURNING id, email, last_login_at
)
INSERT INTO archived_users (user_id, email, last_seen, archived_at)
SELECT id, email, last_login_at, now()
FROM deleted;

UPDATE … RETURNING plus INSERT … SELECT — alle inaktiven User werden soft-gelöscht und ihre Daten ins Archiv kopiert. In einer Transaktion, mit definierter Reihenfolge.

Besonderheiten

RETURNING ist Postgres- und SQLite-spezifisch.

MySQL und Oracle haben keine direkte Entsprechung — Oracle hat RETURNING INTO für PL/SQL, MySQL braucht LAST_INSERT_ID() plus zweite Query. Wer plattformübergreifend bleiben muss, kann RETURNING nicht nutzen; in einer Postgres-only-App ist es die idiomatische Wahl.

Alte Werte in UPDATE … RETURNING bekommen ist nicht trivial.

RETURNING liefert immer die NEUEN Werte. Wenn du den alten Wert brauchst (z. B. für Audit-Logging), gibt es zwei Wege: Variante 1 — vor dem UPDATE per SELECT-FOR-UPDATE die Zeile holen. Variante 2 — den Update über einen CTE schicken, der vorher die alten Werte liest. Pattern: WITH old AS (SELECT … FOR UPDATE) UPDATE … RETURNING ….

RETURNING * ist tueckischer als gedacht.

Wie bei SELECT * werden alle Spalten zurückgegeben — auch neue, die später dazukommen. Treiber-Code, der die Spalten per Index addressiert, bricht bei Schema-Änderungen. Spalten explizit benennen, sonst gleicher Bug-Modus wie SELECT *.

RETURNING + ON CONFLICT — der was_inserted-Trick.

Postgres setzt das System-Spalten-Feld xmax nur bei UPDATEs auf eine Transaction-ID; bei reinen INSERTs bleibt xmax = 0. Das macht (xmax = 0) AS was_inserted zum schnellsten Weg, Insert von Update zu unterscheiden — kein zusätzlicher Roundtrip nötig.

WITH-CTE + Modifying-Statements ist sehr maechtig.

Du kannst mehrere INSERT/UPDATE/DELETE in einem Statement verketten — alle in der gleichen Transaktion, jeweils mit RETURNING. Klassisch für komplexe Migrationen oder das „atomare Verschieben”-Pattern (DELETE + INSERT). Reihenfolge wichtig: WITH del AS (DELETE …) INSERT … SELECT … FROM del.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu SQL-Grundlagen

Zur Übersicht