How do you archive rows into a history table and delete them without round-tripping through the application?
answer
- Never pull rows through the client to move them
- Same predicate twice is not the same rows
- Freeze the key set once
- Copy and delete belong in one transaction
basics
~20 sUse INSERT INTO archive ... SELECT ... FROM source, then a DELETE over the identical row set, both server-side in one transaction. Pin that set — capture the keys once — so the DELETE cannot remove rows the INSERT never copied.
solid answer
~50 sKeep the rows inside the server: `INSERT INTO orders_archive (id, customer_id, total, closed_at) SELECT id, customer_id, total, closed_at FROM orders WHERE ...` copies them with no network transfer and no per-row statements, then a `DELETE` removes them. The trap is running the two statements over the *same predicate* rather than the same *rows*: if a row can start matching the predicate between the two statements — say `status` flips to `'CLOSED'` — the `DELETE` removes a row the `INSERT` never copied, and the data is gone. Pin the set first by capturing its keys into a small table (or a fixed key window), and drive both statements from those keys. Run the pair in one transaction so a failure undoes both, and chunk it by key window when the volume is large. Where the engine supports it, `DELETE ... RETURNING` feeding an insert, or `DELETE ... OUTPUT ... INTO`, does the move in a single statement.
code
sql · 6 lines-- Hazard: shared predicate, not a shared row set.
-- A row that becomes CLOSED between the statements is deleted un-archived.
INSERT INTO orders_archive (id, customer_id, total, closed_at)
SELECT id, customer_id, total, closed_at FROM orders WHERE status = 'CLOSED';
DELETE FROM orders WHERE status = 'CLOSED';go deeper
Know that INSERT INTO archive ... SELECT ... FROM source copies rows entirely inside the database, and that fetching rows into the application only to send them back is wasted transfer.
Explain why running the copy and the delete over the same predicate is not the same as running them over the same rows, and show the pinned-key-set fix.
Scope the transaction per chunk, make a crashed run safely re-runnable, order deletes around foreign keys, and reach for a single-statement RETURNING/OUTPUT move where the engine has one.
Treat archival as a designed lifecycle: where history lives, how retention is expressed once, and whether partition detach should replace copy-and-delete for the largest tables.
## The shape of the job "Move rows older than a year into history" is one of the most common batch tasks there is, and it has an obvious wrong implementation: `SELECT` the rows into the application, `INSERT` them back one at a time into the archive table, then `DELETE` them one at a time. Every row crosses the network twice and costs three statements. The set-based version keeps the data server-side entirely. ## Step 1: copy with INSERT ... SELECT ```sql INSERT INTO orders_archive (id, customer_id, total, closed_at) SELECT id, customer_id, total, closed_at FROM orders WHERE status = 'CLOSED' AND closed_at < DATE '2024-01-01'; ``` One statement, no rows in flight to the client, and the engine can stream directly from the source into the target. Always write explicit column lists on both sides: a positional `SELECT *` into an archive table silently breaks the day either table gains a column. ## Step 2: delete — and the trap The naive second statement repeats the predicate: ```sql DELETE FROM orders WHERE status = 'CLOSED' AND closed_at < DATE '2024-01-01'; ``` This is correct only if the set of rows matching that predicate cannot change between the two statements. It usually can. An order that is closed by ordinary application traffic in the seconds between the `INSERT` and the `DELETE` — with a `closed_at` value that satisfies the range, or under any predicate over mutable state — now matches the `DELETE` but was never copied. The row is destroyed. The failure is silent and permanent, and it will not reproduce in testing where nothing else is writing. ## The fix: pin the set, not the predicate Decide *once* which rows are moving, and let both statements refer to that decision: ```sql -- 1. Freeze the batch INSERT INTO orders_to_archive (id) SELECT id FROM orders WHERE status = 'CLOSED' AND closed_at < DATE '2024-01-01' ORDER BY id FETCH FIRST 5000 ROWS ONLY; -- 2. Copy exactly those rows INSERT INTO orders_archive (id, customer_id, total, closed_at) SELECT o.id, o.customer_id, o.total, o.closed_at FROM orders o JOIN orders_to_archive b ON b.id = o.id; -- 3. Delete exactly those rows DELETE FROM orders WHERE id IN (SELECT id FROM orders_to_archive); ``` Now the copy and the delete are provably over the same rows, because both are driven by the frozen key list. The same trick gives you chunking for free — the `FETCH FIRST` bounds each batch, and the job loops until the freeze step selects nothing. ## Atomicity Wrap steps 2 and 3 in one transaction. If the process dies between them, you otherwise get one of two bad states: rows copied but not deleted (a re-run duplicates them in the archive) or rows deleted but not copied (data loss). One transaction per chunk keeps each unit small and makes a crashed job simply *unfinished* rather than inconsistent. Belt and braces: give the archive table the same primary key as the source, so an accidental repeat copy raises a key violation instead of silently duplicating. ## One-statement variants Several engines can do the move atomically in a single statement, which removes the divergence problem by construction. PostgreSQL allows a data-modifying CTE: ```sql WITH moved AS ( DELETE FROM orders WHERE status = 'CLOSED' AND closed_at < DATE '2024-01-01' RETURNING id, customer_id, total, closed_at ) INSERT INTO orders_archive (id, customer_id, total, closed_at) SELECT id, customer_id, total, closed_at FROM moved; ``` SQL Server expresses the same idea with `DELETE ... OUTPUT deleted.* INTO orders_archive`. Both are vendor extensions rather than standard SQL, so portable code stays with the two-statement, pinned-set form — but if you are on an engine that has one, it is the cleanest answer available. ## Ordering around foreign keys If child tables reference the rows being moved, the archive copy has to happen for children too, and the deletes have to run child-first (or the constraint has to be defined with a cascading action). Getting this order wrong shows up immediately as a constraint violation rather than silently, which is the one mercy in this job. ## What to say in an interview Name `INSERT ... SELECT` as the mechanism that keeps rows server-side; then, unprompted, raise the same-predicate-twice hazard and the pinned-key-set fix; then transaction scoping per chunk; then, if the engine offers it, the single-statement `RETURNING`/`OUTPUT` variant. That progression — mechanism, correctness hazard, fix, engine shortcut — is exactly the reasoning the question is testing.
- Should the archive INSERT and the purge DELETE run in the same transaction?Yes, per chunk. Otherwise a crash between them leaves rows copied but not deleted — a re-run then duplicates them — or deleted but not copied, which is data loss. One short transaction per chunk keeps the unit small and makes a half-finished job merely incomplete. Giving the archive table the source's primary key turns an accidental repeat copy into a loud key violation.
- Some engines can do this in one statement. How?PostgreSQL allows a data-modifying CTE: WITH moved AS (DELETE FROM orders WHERE ... RETURNING ...) INSERT INTO orders_archive SELECT ... FROM moved. SQL Server writes DELETE ... OUTPUT deleted.* INTO orders_archive. Both guarantee the copied set equals the deleted set, because there is only one set. Neither is standard SQL, so portable jobs keep the pinned-key two-statement form.
- Why write explicit column lists rather than INSERT INTO orders_archive SELECT * FROM orders?Positional matching breaks the moment either table gains, drops or reorders a column — usually with a type error, sometimes by silently loading values into the wrong columns. Naming both the target columns and the selected expressions makes the mapping explicit and survives schema change.
saying these in an interview costs you the question
- Reads rows into the app to insert them back
- Repeats the same predicate for copy and delete
- Runs the copy and delete in separate transactions
- Uses SELECT * into the archive table
- Ignores child rows referencing the moved rows