DuckDB One Trick: Use RETURNING to Audit Your ETL in One Query

DuckDB's RETURNING clause lets you capture the results of INSERT, UPDATE, and DELETE in a single query. One trick to replace complex ETL audit logic with a single SQL statement.

The Problem: ETL Audit Logic Is a Pain

You’re building an ETL pipeline. Every day, you need to:

  1. Insert new records into a table
  2. Update existing records
  3. 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

MetricOld ApproachRETURNING Approach
Queries for 10K rows40,0003
Python code lines~30~10
Round-trip latency40,000 × network RTT3 × network RTT
Memory overheadHigh (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

ScenarioUse 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

  1. RETURNING replaces 4 queries with 1 — just add RETURNING * to INSERT/UPDATE/DELETE
  2. Bulk operations are key — use RETURNING with batch inserts/updates, not row-by-row
  3. Combine with TEMP tables — capture RETURNING results into temp tables for batch logging
  4. Conditional RETURNING — use CASE WHEN for 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.

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

Built with Hugo
Theme Stack designed by Jimmy

⚠️ This site is an independent community project, not affiliated with, endorsed by, or sponsored by the DuckDB Foundation or official DuckDB project.

"DuckDB" is a registered trademark of the DuckDB Foundation. This site uses the name solely for factual description purposes.

All content is for educational and community promotion purposes only and does not constitute any commercial service.