Featured image of post DuckDB in Action: Advanced Data Cleaning and ETL Pipelines

DuckDB in Action: Advanced Data Cleaning and ETL Pipelines

Master advanced DuckDB data cleaning techniques: log parsing, mixed format handling, JSON flattening, and production-grade data quality validation frameworks.

Introduction

In our previous article, we covered the fundamentals of DuckDB-based data cleaning and ETL pipelines. This article dives deeper into more complex real-world scenarios: log file parsing, mixed-format data cleaning, nested JSON flattening, and production-grade data quality validation frameworks. These patterns are ubiquitous in e-commerce, finance, and IoT industries.

Scenario 1: Parsing Semi-Structured Log Data

Problem Background

Your company’s application servers generate large volumes of Nginx access logs daily, with the following format:

192.168.1.100 - - [04/Sep/2026:10:15:30 +0800] "GET /api/products HTTP/1.1" 200 1234 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
10.0.0.55 - admin [04/Sep/2026:10:15:31 +0800] "POST /api/orders HTTP/1.1" 201 567 "https://example.com" "curl/7.81.0"

These logs contain IP addresses, timestamps, HTTP methods, paths, status codes, response sizes, and User-Agents. We need to extract structured data from them for traffic analysis.

DuckDB Solution

DuckDB provides powerful regular expression functions regexp_extract and regexp_match to directly extract fields from log lines:

-- Read and parse Nginx access logs
CREATE TABLE nginx_access_logs AS
SELECT
    regexp_extract(line, '^(\\S+)', 1) AS client_ip,
    regexp_extract(line, '^\\S+ \\S+ (\\S+)', 1) AS remote_user,
    regexp_extract(line, '\\[(\\d{2}/\\w{3}/\\d{4}:\\d{2}:\\d{2}:\\d{2}) [+\\-]\\d{4}\\]', 1) AS timestamp_str,
    regexp_extract(line, '"(\\w+) (\\S+) (\\S+)"', 1) AS http_method,
    regexp_extract(line, '"\\w+ (\\S+) \\S+"', 1) AS request_path,
    regexp_extract(line, '"\\w+ \\S+ (\\S+)"', 1) AS http_version,
    regexp_extract(line, '"\\w+ \\S+ \\S+" (\\d{3})', 1) AS status_code,
    regexp_extract(line, '"\\w+ \\S+ \\S+" \\d{3} (\\d+)', 1) AS response_size,
    regexp_extract(line, '"([^"]*)" "[^"]*"$', 1) AS referer,
    regexp_extract(line, '"[^"]*" "([^"]*)"', 1) AS user_agent
FROM read_csv_auto('logs/nginx_2026-09-04.log');

-- View parsed results
SELECT * FROM nginx_access_logs LIMIT 5;

Architecture Diagram

Fig: Nginx log parsing architecture — from raw text to structured columns

Timestamp Conversion and Normalization

-- Convert parsed timestamp strings to TIMESTAMP and extract useful dimensions
CREATE TABLE parsed_access_logs AS
SELECT
    client_ip,
    remote_user,
    TRY_CAST(timestamp_str AS TIMESTAMP) AS log_time,
    http_method,
    request_path,
    status_code::INTEGER AS status,
    response_size::BIGINT AS bytes_sent,
    user_agent,
    -- Extract URL path category
    CASE
        WHEN request_path LIKE '/api/%' THEN 'api'
        WHEN request_path LIKE '/static/%' THEN 'static'
        WHEN request_path LIKE '/images/%' THEN 'images'
        ELSE 'other'
    END AS path_category,
    -- Determine if search engine bot
    CASE
        WHEN user_agent ILIKE '%googlebot%' THEN 'google'
        WHEN user_agent ILIKE '%bingbot%' THEN 'bing'
        WHEN user_agent ILIKE '%baiduspider%' THEN 'baidu'
        ELSE 'human'
    END AS bot_type,
    -- Categorize response status
    CASE
        WHEN status BETWEEN 200 AND 299 THEN 'success'
        WHEN status BETWEEN 300 AND 399 THEN 'redirect'
        WHEN status BETWEEN 400 AND 499 THEN 'client_error'
        WHEN status BETWEEN 500 AND 599 THEN 'server_error'
        ELSE 'unknown'
    END AS status_category
FROM nginx_access_logs
WHERE TRY_CAST(timestamp_str AS TIMESTAMP) IS NOT NULL;

-- Verify conversion results
SELECT
    path_category,
    bot_type,
    status_category,
    COUNT(*) AS request_count,
    ROUND(AVG(bytes_sent)) AS avg_bytes
FROM parsed_access_logs
GROUP BY path_category, bot_type, status_category
ORDER BY request_count DESC;

Terminal Output

Fig: DuckDB CLI output showing log parsing SQL execution results

Scenario 2: Cleaning Mixed-Format CSV Data

Problem Background

During a data migration, you received sales data from three different suppliers, each using a different CSV format:

  • Supplier A: Standard format, comma-separated, dates as YYYY-MM-DD
  • Supplier B: Semicolon-separated, dates as DD/MM/YYYY, prices in European format (. as thousands separator, , as decimal point)
  • Supplier C: Tab-separated, contains multi-line string fields (wrapped in double quotes), date field may be missing

Unified Cleaning Pipeline

-- Step 1: Read data in three different formats
CREATE TEMP TABLE supplier_a AS
SELECT
    order_id,
    TRY_CAST(customer_id AS INTEGER) AS customer_id,
    product_name,
    TRY_CAST(REPLACE(price, '.', '') AS DECIMAL(12,2)) AS price,
    TRY_CAST(date AS DATE) AS order_date,
    quantity,
    'supplier_a' AS source
FROM read_csv_auto('data/supplier_a.csv', sep=',');

CREATE TEMP TABLE supplier_b AS
SELECT
    order_id,
    TRY_CAST(customer_id AS INTEGER) AS customer_id,
    product_name,
    TRY_CAST(REPLACE(REPLACE(price, '.', ''), ',', '.') AS DECIMAL(12,2)) AS price,
    TRY_CAST(strptime(date, '%d/%m/%Y') AS DATE) AS order_date,
    TRY_CAST(quantity AS INTEGER) AS quantity,
    'supplier_b' AS source
FROM read_csv_auto('data/supplier_b.csv', sep=';');

CREATE TEMP TABLE supplier_c AS
SELECT
    order_id,
    TRY_CAST(customer_id AS INTEGER) AS customer_id,
    product_name,
    TRY_CAST(price AS DECIMAL(12,2)) AS price,
    TRY_CAST(date AS DATE) AS order_date,
    TRY_CAST(quantity AS INTEGER) AS quantity,
    'supplier_c' AS source
FROM read_csv_auto('data/supplier_c.tsv', sep='\t');

-- Step 2: Merge and unify formats
CREATE TABLE unified_sales AS
SELECT order_id, customer_id, product_name, price, order_date, quantity, source
FROM supplier_a
UNION ALL
SELECT order_id, customer_id, product_name, price, order_date, quantity, source
FROM supplier_b
UNION ALL
SELECT order_id, customer_id, product_name, price, order_date, quantity, source
FROM supplier_c;

-- Step 3: Data quality checks
SELECT
    source,
    COUNT(*) AS total_records,
    COUNT(order_id) AS valid_ids,
    COUNT(customer_id) AS valid_customers,
    COUNT(order_date) AS valid_dates,
    COUNT(price) AS valid_prices,
    COUNT(quantity) AS valid_quantities,
    ROUND(AVG(price), 2) AS avg_price,
    MIN(price) AS min_price,
    MAX(price) AS max_price
FROM unified_sales
GROUP BY source;

Handling Duplicate Orders and Conflicting Data

-- Detect duplicate orders across suppliers
SELECT
    order_id,
    COUNT(*) AS occurrence_count,
    GROUP_CONCAT(DISTINCT source) AS sources,
    GROUP_CONCAT(DISTINCT customer_id) AS customer_ids,
    SUM(price) AS total_price_sum
FROM unified_sales
GROUP BY order_id
HAVING COUNT(*) > 1;

-- Smart deduplication: prefer records with most complete data
CREATE TABLE deduplicated_sales AS
WITH ranked AS (
    SELECT *,
        row_number() OVER (
            PARTITION BY order_id
            ORDER BY
                CASE WHEN order_date IS NOT NULL THEN 0 ELSE 1 END,
                CASE WHEN customer_id IS NOT NULL THEN 0 ELSE 1 END,
                CASE source WHEN 'supplier_a' THEN 0 WHEN 'supplier_b' THEN 1 ELSE 2 END
        ) AS rank
    FROM unified_sales
)
SELECT * FROM ranked WHERE rank = 1;

SELECT COUNT(*) AS original_count FROM unified_sales;
SELECT COUNT(*) AS deduplicated_count FROM deduplicated_sales;

Scenario 3: Cleaning Nested JSON Data

Problem Background

An e-commerce platform returns order data as nested JSON via API, containing order basics, product list, shipping address, and payment method. We need to flatten this into a relational table.

Flattening Nested JSON

-- Read nested JSON order data
CREATE TABLE raw_orders_json AS
SELECT * FROM read_json_auto('data/orders.json');

-- Use JSON unpacking to flatten nested structure
CREATE TABLE flattened_orders AS
SELECT
    o.order_id,
    o.customer_id,
    o.order_time,
    o.status,
    item.product_id,
    item.product_name,
    item.quantity,
    item.unit_price,
    item.discount_percent,
    addr.country,
    addr.province,
    addr.city,
    addr.district,
    addr.full_address,
    payment.method,
    payment.amount AS paid_amount,
    payment.transaction_id
FROM raw_orders_json o,
    LATERAL unpack(o.items) AS item,
    LATERAL o.shipping_address AS addr,
    LATERAL unpack(o.payments) AS payment;

-- View flattened results
SELECT * FROM flattened_orders LIMIT 10;

JSON Data Cleaning and Validation

-- Clean and validate flattened data
CREATE TABLE clean_orders AS
SELECT
    order_id,
    TRY_CAST(customer_id AS BIGINT) AS customer_id,
    TRY_CAST(order_time AS TIMESTAMP) AS order_time,
    LOWER(TRIM(status)) AS order_status,
    TRY_CAST(product_id AS BIGINT) AS product_id,
    TRIM(product_name) AS product_name,
    GREATEST(TRY_CAST(quantity AS INTEGER), 0) AS quantity,
    GREATEST(TRY_CAST(unit_price AS DECIMAL(10,2)), 0) AS unit_price,
    LEAST(GREATEST(TRY_CAST(discount_percent AS DECIMAL(5,2)), 0), 100) AS discount,
    UPPER(country) AS country,
    TRIM(province) AS province,
    TRIM(city) AS city,
    REPLACE(full_address, E'\n', ' ') AS address_clean,
    UPPER(method) AS pay_method,
    TRY_CAST(paid_amount AS DECIMAL(12,2)) AS paid_amount,
    TRIM(transaction_id) AS txn_id
FROM flattened_orders
WHERE TRY_CAST(customer_id AS BIGINT) IS NOT NULL
  AND TRY_CAST(unit_price AS DECIMAL(10,2)) IS NOT NULL;

-- Data statistics
SELECT
    order_status,
    pay_method,
    COUNT(DISTINCT order_id) AS order_count,
    COUNT(*) AS line_item_count,
    ROUND(SUM(unit_price * quantity * (1 - discount/100)), 2) AS total_revenue,
    ROUND(AVG(paid_amount), 2) AS avg_payment
FROM clean_orders
GROUP BY order_status, pay_method
ORDER BY order_count DESC;

Scenario 4: Production-Grade Data Quality Validation Framework

Quality Rule Definition

In production environments, data cleaning isn’t just about format conversion — it requires automated quality validation mechanisms. DuckDB supports defining quality rules through SQL and generating reports:

-- Create data quality rules table
CREATE TABLE data_quality_rules (
    rule_id VARCHAR PRIMARY KEY,
    table_name VARCHAR,
    column_name VARCHAR,
    rule_type VARCHAR,
    rule_expression VARCHAR,
    severity VARCHAR,
    description VARCHAR
);

-- Insert quality rules
INSERT INTO data_quality_rules VALUES
('dq_001', 'clean_orders', 'order_id', 'not_null', 'order_id IS NOT NULL', 'error', 'Order ID must not be null'),
('dq_002', 'clean_orders', 'customer_id', 'not_null', 'customer_id IS NOT NULL', 'error', 'Customer ID must not be null'),
('dq_003', 'clean_orders', 'unit_price', 'positive', 'unit_price > 0', 'error', 'Unit price must be positive'),
('dq_004', 'clean_orders', 'quantity', 'range', 'quantity >= 1 AND quantity <= 999', 'warning', 'Quantity should be between 1 and 999'),
('dq_005', 'clean_orders', 'discount', 'range', 'discount >= 0 AND discount <= 100', 'warning', 'Discount should be between 0 and 100 percent'),
('dq_006', 'clean_orders', 'order_time', 'not_null', 'order_time IS NOT NULL', 'error', 'Order time must not be null'),
('dq_007', 'clean_orders', 'paid_amount', 'match', 'ABS(paid_amount - unit_price * quantity * (1 - discount/100)) < 0.01', 'warning', 'Paid amount should match calculated amount'),
('dq_008', 'clean_orders', 'country', 'pattern', 'country REGEXP /^[A-Z]{2}$/', 'info', 'Country code should be 2 uppercase letters');

Note: DuckDB doesn’t have an EVALUATE_EXPRESSION function. Below is a practical Python implementation:

import duckdb

con = duckdb.connect('orders.duckdb')

def run_quality_checks(conn, table_name, rules):
    """Execute data quality checks"""
    results = []
    total_rows = conn.execute(f"SELECT COUNT(*) FROM {table_name}").fetchone()[0]
    
    for rule in rules:
        rule_id = rule['rule_id']
        condition = rule['condition']
        severity = rule['severity']
        description = rule['description']
        
        query = f"SELECT COUNT(*) FROM {table_name} WHERE NOT ({condition})"
        violation_count = conn.execute(query).fetchone()[0]
        rate = violation_count * 100.0 / total_rows if total_rows > 0 else 0
        
        results.append({
            'rule_id': rule_id,
            'table': table_name,
            'violation_count': violation_count,
            'violation_rate_pct': round(rate, 2),
            'severity': severity,
            'description': description,
            'total_rows': total_rows
        })
    
    return results

rules = [
    {'rule_id': 'dq_001', 'condition': 'order_id IS NOT NULL', 'severity': 'error', 'description': 'Order ID must not be null'},
    {'rule_id': 'dq_002', 'condition': 'customer_id IS NOT NULL', 'severity': 'error', 'description': 'Customer ID must not be null'},
    {'rule_id': 'dq_003', 'condition': 'unit_price > 0', 'severity': 'error', 'description': 'Unit price must be positive'},
    {'rule_id': 'dq_004', 'condition': 'quantity >= 1 AND quantity <= 999', 'severity': 'warning', 'description': 'Quantity should be between 1 and 999'},
    {'rule_id': 'dq_005', 'condition': 'discount >= 0 AND discount <= 100', 'severity': 'warning', 'description': 'Discount should be between 0 and 100 percent'},
]

report = run_quality_checks(con, 'clean_orders', rules)
con.execute("CREATE TABLE quality_check_report AS SELECT * FROM report")

for r in report:
    print(f"[{r['severity'].upper()}] {r['rule_id']}: {r['violation_count']} violations ({r['violation_rate_pct']}%) - {r['description']}")

Quality Report Summary Query

-- Summarize quality report
SELECT
    severity,
    rule_id,
    description,
    violation_count,
    violation_rate_pct,
    total_rows,
    CASE
        WHEN severity = 'error' AND violation_count > 0 THEN 'BLOCK'
        WHEN severity = 'warning' AND violation_rate_pct > 5 THEN 'REVIEW'
        ELSE 'PASS'
    END AS action_required
FROM quality_check_report
ORDER BY
    CASE severity WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
    violation_count DESC;

Scenario 5: Incremental ETL and Data Synchronization

Incremental Update Pattern

In production, data is typically updated incrementally. We need to design an ETL flow that handles incremental updates:

-- Create incremental ETL stored procedure
CREATE OR REPLACE PROCEDURE incremental_etl_load()
LANGUAGE SQL
AS $$
BEGIN
    -- Step 1: Create incremental staging table
    CREATE TEMP TABLE IF NOT EXISTS etl_staging AS
    SELECT * FROM read_csv_auto('data/incremental_orders_2026-09.csv')
    WHERE 1=0;

    -- Step 2: Load incremental data
    INSERT INTO etl_staging
    SELECT * FROM read_csv_auto('data/incremental_orders_2026-09.csv');

    -- Step 3: Upsert logic
    MERGE INTO clean_orders AS target
    USING etl_staging AS source
    ON target.order_id = source.order_id
    WHEN MATCHED THEN
        UPDATE SET
            customer_id = source.customer_id,
            order_time = source.order_time,
            status = source.status,
            updated_at = CURRENT_TIMESTAMP
    WHEN NOT MATCHED THEN
        INSERT (order_id, customer_id, product_id, product_name,
                quantity, unit_price, discount, order_time, status)
        VALUES (
            source.order_id, source.customer_id, source.product_id,
            source.product_name, source.quantity, source.unit_price,
            source.discount, source.order_time, source.status
        );

    -- Step 4: Record ETL metadata
    INSERT INTO etl_audit_log (
        load_type, source_file, rows_loaded, rows_updated,
        rows_inserted, started_at, completed_at
    )
    SELECT
        'incremental',
        'incremental_orders_2026-09.csv',
        (SELECT COUNT(*) FROM etl_staging),
        (SELECT COUNT(*) FROM clean_orders WHERE updated_at > CURRENT_DATE - 1),
        (SELECT COUNT(*) FROM etl_staging s LEFT JOIN clean_orders c ON s.order_id = c.order_id WHERE c.order_id IS NULL),
        CURRENT_TIMESTAMP - INTERVAL '5' SECOND,
        CURRENT_TIMESTAMP;
END;
$$;

-- Execute incremental ETL
CALL incremental_etl_load();

ETL Audit Log

-- Create audit log table
CREATE TABLE IF NOT EXISTS etl_audit_log (
    log_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    load_type VARCHAR,
    source_file VARCHAR,
    rows_loaded INTEGER,
    rows_updated INTEGER,
    rows_inserted INTEGER,
    started_at TIMESTAMP,
    completed_at TIMESTAMP,
    duration_seconds DOUBLE,
    status VARCHAR
);

-- Calculate execution duration
UPDATE etl_audit_log
SET
    duration_seconds = (completed_at - started_at)::DOUBLE,
    status = 'SUCCESS'
WHERE completed_at IS NOT NULL AND status IS NULL;

-- Query ETL performance trends
SELECT
    DATE(started_at) AS load_date,
    load_type,
    COUNT(*) AS run_count,
    AVG(duration_seconds) AS avg_duration,
    SUM(rows_loaded) AS total_rows,
    SUM(rows_inserted) AS total_inserted,
    SUM(rows_updated) AS total_updated
FROM etl_audit_log
GROUP BY DATE(started_at), load_type
ORDER BY load_date DESC, load_type;

Summary and Best Practices

This article covered five core scenarios of advanced data cleaning and ETL pipelines in DuckDB:

ScenarioCore TechniqueKey Functions
Log ParsingRegular Expressionsregexp_extract, regexp_match
Mixed Format CleaningMulti-source Mergeread_csv_auto, strptime, UNION ALL
JSON FlatteningNested Unpackingunpack, LATERAL
Quality ValidationRule EngineDynamic SQL + Python scripting
Incremental ETLUpsertMERGE, PROCEDURE

Production recommendations:

  1. Layered processing: raw → staging → clean → mart, with clear quality gates at each layer
  2. Keep audit trails: Raw data is always read-only; all cleaned results go to new tables
  3. Quality gates: Critical rules (e.g., primary key uniqueness, required fields not null) block downstream if they fail
  4. Incremental first: Use MERGE or window functions for incremental updates instead of full reruns
  5. Monitoring & alerting: Feed quality reports into alerting systems; auto-notify when anomaly rates exceed thresholds

Architecture Diagram

Fig: Advanced data cleaning ETL architecture — from multi-source dirty data to high-quality data warehouse

SQL Execution Result

Fig: DuckDB CLI output showing incremental ETL quality check results

For more DuckDB实战技巧, visit DuckDB Lab (duckdblab.org).

📺 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.