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.


本文信息

项目内容
DuckDB 版本v1.5.x(部分功能基于 v2.0 Preview)
最后验证2026-09-12
测试环境Linux / x86_64 / 16GB RAM
官方文档DuckDB Documentation
GitHubpengzz9527/duckdb-blog

如发现错误,欢迎通过 GitHub Issue 或邮件 [email protected] 反馈。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

Built with Hugo
Theme Stack designed by Jimmy