The Problem: ETL Audit Logic Is a Pain
You’re building an ETL pipeline. Every day, you need to:
- Insert new records into a table
- Update existing records
- Keep a log of what changed — which rows were inserted, which were updated, which were deleted
The traditional approach requires multiple queries:
-- Step 1: Find existing IDs
SELECT id FROM customers WHERE id IN (1, 2, 3);
-- Step 2: Insert new records
INSERT INTO customers (id, name) VALUES (1, 'Alice'), (2, 'Bob');
-- Step 3: Update existing records
UPDATE customers SET name = 'Alice Updated' WHERE id = 2;
-- Step 4: Log the changes
INSERT INTO audit_log (action, id, old_value, new_value)
VALUES ('INSERT', 1, NULL, 'Alice'), ('UPDATE', 2, 'Bob', 'Alice Updated');
That’s 4 separate queries for a simple upsert. And that’s just for 2 rows. With 10,000 rows, this becomes a maintenance nightmare.
The One-Line Solution: RETURNING
DuckDB supports the RETURNING clause — the same feature that makes Postgres so powerful. It lets you capture the results of INSERT, UPDATE, and DELETE in the same query.
-- One query: insert AND capture the result
INSERT INTO customers (id, name) VALUES (1, 'Alice') RETURNING *;
-- One query: update AND capture the result
UPDATE customers SET name = 'Alice Updated' WHERE id = 1 RETURNING *;
-- One query: delete AND capture the result
DELETE FROM customers WHERE id = 1 RETURNING *;
One SQL statement replaces 4 queries. That’s the power of RETURNING.
Practical Example: ETL Audit Trail
Let’s build a real-world ETL pipeline with audit logging.
Setup: Create Tables
-- Target table
CREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR,
email VARCHAR,
updated_at TIMESTAMP
);
-- Audit log table
CREATE TABLE audit_log (
action VARCHAR,
id INT,
old_name VARCHAR,
new_name VARCHAR,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
The Old Way: 4 Queries Per Row
# Python pseudo-code
for record in staging_table:
# Check if exists
existing = con.execute(
"SELECT name FROM customers WHERE id = ?", [record.id]
).fetchone()
if existing:
# Update
con.execute(
"UPDATE customers SET name = ? WHERE id = ?",
[record.name, record.id]
)
# Log
con.execute(
"INSERT INTO audit_log (action, id, old_name, new_name) VALUES (?, ?, ?, ?)",
["UPDATE", record.id, existing[0], record.name]
)
else:
# Insert
con.execute(
"INSERT INTO customers (id, name) VALUES (?, ?)",
[record.id, record.name]
)
# Log
con.execute(
"INSERT INTO audit_log (action, id, old_name, new_name) VALUES (?, ?, ?, ?)",
["INSERT", record.id, None, record.name]
)
That’s 4 queries per row × 10,000 rows = 40,000 queries. No wonder your ETL is slow.
The RETURNING Way: 1 Query Per Row
-- Insert with audit log in ONE query
INSERT INTO customers (id, name)
SELECT id, name FROM staging_customers
WHERE id NOT IN (SELECT id FROM customers)
RETURNING id, name;
-- Update with audit log in ONE query
UPDATE customers
SET name = s.name, updated_at = CURRENT_TIMESTAMP
FROM staging_customers s
WHERE customers.id = s.id
RETURNING customers.id, customers.name, s.name AS new_name;
Wait, that’s still 2 queries. Let me show you the real power of RETURNING.
The Real Trick: Bulk RETURNING
The magic happens when you use RETURNING with bulk operations:
-- Step 1: Insert new records AND capture them
CREATE TEMP TABLE inserted AS
INSERT INTO customers (id, name)
SELECT id, name FROM staging_customers
WHERE id NOT IN (SELECT id FROM customers)
RETURNING id, name, 'INSERT' AS action;
-- Step 2: Update existing records AND capture old + new values
CREATE TEMP TABLE updated AS
UPDATE customers c
SET name = s.name, updated_at = CURRENT_TIMESTAMP
FROM staging_customers s
WHERE c.id = s.id AND c.name != s.name
RETURNING c.id, c.name AS old_name, s.name AS new_name, 'UPDATE' AS action;
-- Step 3: Log everything in ONE batch insert
INSERT INTO audit_log (action, id, old_name, new_name)
SELECT action, id, NULL, name FROM inserted
UNION ALL
SELECT action, id, old_name, new_name FROM updated;
Total: 3 queries for 10,000 rows. That’s 13,333x fewer queries than the old approach.
Quantified Results
| Metric | Old Approach | RETURNING Approach |
|---|---|---|
| Queries for 10K rows | 40,000 | 3 |
| Python code lines | ~30 | ~10 |
| Round-trip latency | 40,000 × network RTT | 3 × network RTT |
| Memory overhead | High (bulk Python processing) | Low (SQL-native) |
For a 10,000-row ETL job:
- Old approach: 40,000 queries × 1ms RTT = 40 seconds of network latency alone
- RETURNING approach: 3 queries × 1ms RTT = 3 milliseconds
That’s a 13,000x speedup just from reducing query count.
When to Use RETURNING
| Scenario | Use RETURNING? |
|---|---|
| Insert audit log | ✅ Yes |
| Update with change tracking | ✅ Yes |
| Delete with soft-delete logging | ✅ Yes |
| Bulk upsert with audit | ✅ Yes |
| Simple SELECT queries | ❌ No (use SELECT) |
| Complex business logic | ❌ No (use Python) |
Advanced: RETURNING with Conditional Logic
You can combine RETURNING with CASE WHEN for conditional audit logging:
-- Conditional audit: only log when name changes
UPDATE customers c
SET name = s.name, updated_at = CURRENT_TIMESTAMP
FROM staging_customers s
WHERE c.id = s.id
RETURNING
c.id,
CASE WHEN c.name != s.name THEN 'UPDATE' ELSE 'NOCHANGE' END AS action,
c.name AS old_name,
s.name AS new_name;
This lets you skip audit entries for no-change updates — perfect for incremental ETL where many rows haven’t changed.
Key Takeaways
- RETURNING replaces 4 queries with 1 — just add
RETURNING *to INSERT/UPDATE/DELETE - Bulk operations are key — use RETURNING with batch inserts/updates, not row-by-row
- Combine with TEMP tables — capture RETURNING results into temp tables for batch logging
- Conditional RETURNING — use
CASE WHENfor smart audit filtering
The next time you build an ETL pipeline, remember: one RETURNING clause can replace an entire audit subsystem.
Subscribe to DuckDB Lab for more one-trick tips that save you hours every week.