skip to content

How can DELETE ... RETURNING move rows into an archive table in one statement?

level: seniorimportance: should knowfreq 30%

answer

  1. three statements collapse into one
  2. the delete's own output feeds the insert
  3. predicate evaluated exactly once
  4. a data-modifying statement inside WITH
  5. DELETE ... RETURNING * as a row source

basics

~20 s

DELETE ... RETURNING emits the removed rows as a result set, so a single statement can feed them straight into an INSERT — via a data-modifying CTE in PostgreSQL, or DELETE ... OUTPUT deleted.* INTO archive in T-SQL — with no intervening SELECT.

solid answer

~50 s

Because `DELETE ... RETURNING *` produces the deleted rows as a row source, another statement can consume them. In PostgreSQL the idiom is a data-modifying CTE: ```sql WITH archived AS ( DELETE FROM events WHERE occurred_at < DATE '2024-01-01' RETURNING * ) INSERT INTO events_archive SELECT * FROM archived; ``` SQL Server writes the same thing as `DELETE FROM events OUTPUT deleted.* INTO events_archive WHERE ...`. Either way it is one statement, so there is no window between reading the rows and deleting them in which the set could change, and no third round trip. The caveats are real: engines differ on whether DML is allowed inside `WITH`, the archive table's column list must line up with what you return, and the returned rows are the pre-delete image, which is the only image that exists. Without this feature you fall back to SELECT, INSERT, then DELETE with a repeated predicate.

code

sql · 7 lines
sql
WITH archived AS (
  DELETE FROM events
   WHERE occurred_at < DATE '2024-01-01'
  RETURNING event_id, occurred_at, payload
)
INSERT INTO events_archive (event_id, occurred_at, payload)
SELECT event_id, occurred_at, payload FROM archived;

go deeper

for a junior

Know that DELETE can hand back the rows it removed, and that this is how a row is archived without reading it first.

for a middle

Explain the composition: the DELETE's returned rows become a row source for an INSERT, so the predicate runs once and the two tables cannot disagree.

for a senior

Weigh the portability of data-modifying CTEs against OUTPUT ... INTO, insist on explicit column lists, and recognise that the returned image is the pre-delete one.

for a principal

Set the house pattern for data lifecycle moves — where retention logic lives, which engines the codebase may assume, and whether such statements belong in application code or a maintenance job.

## The problem with SELECT-then-DELETE The naive way to move rows out of a hot table is three statements: `SELECT` the rows that qualify, `INSERT` them into the archive, then `DELETE` them from the source. That has two weaknesses. It costs three round trips and materialises the rows in the client for no reason. And it repeats the predicate, so the `DELETE` re-evaluates `occurred_at < …` independently of the `SELECT` — meaning the set of rows deleted is not necessarily the set of rows archived if concurrent writes shift the boundary. Fixing that properly means capturing keys and deleting by key, which is more code again. ## The one-statement form `DELETE ... RETURNING` gives the statement itself a result set of exactly the rows it removed. In an engine that permits a data-modifying statement inside a `WITH` clause, that result set becomes a row source for a second statement: ```sql WITH archived AS ( DELETE FROM events WHERE occurred_at < DATE '2024-01-01' RETURNING * ) INSERT INTO events_archive (event_id, occurred_at, payload) SELECT event_id, occurred_at, payload FROM archived; ``` The `DELETE` runs, hands its removed rows to the CTE, and the `INSERT` consumes them. The rows inserted are, by construction, precisely the rows deleted — the predicate is evaluated once. Listing the columns explicitly on both sides is worth the extra typing: `INSERT INTO archive SELECT *` breaks silently the day someone adds a column to one table and not the other. ## The OUTPUT spelling SQL Server expresses the same idea with `OUTPUT ... INTO`, which routes the clause's output directly into a target table rather than to the client: ```sql DELETE FROM events OUTPUT deleted.event_id, deleted.occurred_at, deleted.payload INTO events_archive (event_id, occurred_at, payload) WHERE occurred_at < '2024-01-01'; ``` Note the clause order: `OUTPUT` sits between the target and the `WHERE`. The `deleted` pseudo-table holds the pre-delete image, which is the only image a delete has. Without `INTO`, the same rows stream back to the client instead. ## What the pattern buys Three things. One statement instead of three, so fewer round trips on what is often a large maintenance job. One evaluation of the predicate, so archive and source cannot disagree about which rows moved. And a single unit of work: because it is one statement, a failure anywhere in it leaves neither the delete nor the archive insert half-applied. The same shape solves related problems. `UPDATE ... RETURNING` feeding an `INSERT` writes an audit row for every row it changed. `INSERT ... RETURNING` feeding another `INSERT` populates a child table with the parent keys the server just generated — the classic "insert the order, then insert its lines against the new order id" without a round trip in between. ## Caveats worth stating Portability is the first. Allowing DML inside `WITH` is a PostgreSQL capability, not something to assume; several engines accept `RETURNING` on a standalone statement but not inside a CTE. Where the feature is missing, `OUTPUT ... INTO` or a per-dialect rewrite is the answer. Second, shape: the returned column list and the archive table's column list must correspond in count, order and type. Use explicit lists. Third, the returned rows are the pre-delete image and nothing else — you cannot enrich them with columns the deleted table never had, though the outer `SELECT` over the CTE can add literals and expressions (`SELECT event_id, occurred_at, payload, CURRENT_TIMESTAMP FROM archived`), which is the usual way to stamp an archive timestamp. Fourth, ordering inside the CTE is undefined, as with any `RETURNING` output; if the archive insert cares about order, sort in the outer `SELECT`. Finally, a very large one-shot move is still a very large statement. Deciding how to size such a job is a separate concern from the syntax; the pattern here is about how to express the move correctly, not about how much to move at once.

  • How would you stamp an archive timestamp onto the rows the DELETE returned?
    Add the expression in the outer SELECT over the CTE: `SELECT event_id, occurred_at, payload, CURRENT_TIMESTAMP FROM archived`. The returned rows carry only the deleted table's columns, but the query wrapped around them can project anything a SELECT list can.
  • What is the equivalent trick for inserting child rows against server-generated parent keys?
    Put the parent INSERT in a CTE with `RETURNING order_id`, then have the outer INSERT select from it to populate the child table. The generated keys never leave the server, so no round trip is needed between the two inserts.

saying these in an interview costs you the question

  • Insists you must SELECT the rows before deleting them
  • Assumes every engine allows DML inside a WITH clause
  • Uses INSERT INTO archive SELECT * without column lists
  • Thinks DELETE ... RETURNING can show a post-delete image
  • Believes the predicate is re-evaluated for the archive insert

context