Featured image of post DuckDB in Action: Working with Pandas/Polars

DuckDB in Action: Working with Pandas/Polars

Why DuckDB + Pandas/Polars?

In data engineering, we often face scenarios where raw data is stored in CSV or Parquet files, needs SQL-based exploratory analysis, but also requires complex feature engineering or modeling in Python. Using Pandas/Polars alone puts memory pressure on large files, while using SQL alone lacks flexibility.

DuckDB’s killer feature fills this gap perfectly — it’s both an embedded OLAP database and a first-class citizen in Pandas/Polars, enabling millisecond-level data exchange.

Installation and Basic Setup

pip install duckdb pandas polars
import duckdb
import pandas as pd
import polars as pl

# Initialize connection (in-memory by default)
con = duckdb.connect(":memory:")

Use Case 1: CSV Direct to DuckDB, Then Convert to Pandas DataFrame

Business scenario: E-commerce order data is stored as CSV. First, clean and aggregate with SQL, then pass to Pandas for feature engineering.

# Read CSV into DuckDB (zero-copy advantage)
con.sql("""
    CREATE TABLE orders AS
    SELECT * FROM read_csv_auto('orders.csv')
""")

# Filter and aggregate in DuckDB (SQL is more efficient)
agg_result = con.sql("""
    SELECT 
        customer_id,
        COUNT(*) AS order_count,
        SUM(amount) AS total_spent,
        AVG(amount) AS avg_order_value
    FROM orders
    WHERE order_date >= '2024-01-01'
      AND status = 'completed'
    GROUP BY customer_id
""").df()  # One line to Pandas DataFrame

print(agg_result.head())

Output:

   customer_id  order_count  total_spent  avg_order_value
0      1000234            5      1250.50          250.10
1      1000567            3       870.00          290.00
2      1000891            8      2100.75          262.59
3      1001234            2       450.00          225.00
4      1001567            6      1680.25          280.04

Key advantage: read_csv_auto auto-infer column types, df() method uses Arrow zero-copy transfer — 10GB data exchange with near-zero overhead.

Use Case 2: DuckDB Query Results Directly to Polars DataFrame

Polars is a high-performance DataFrame library implemented in Rust. When used with DuckDB, performance is particularly outstanding.

# DuckDB executes complex window function query
results = con.sql("""
    WITH ranked_orders AS (
        SELECT 
            customer_id,
            order_date,
            amount,
            ROW_NUMBER() OVER (
                PARTITION BY customer_id 
                ORDER BY order_date DESC
            ) AS rn,
            LAG(amount, 1) OVER (
                PARTITION BY customer_id 
                ORDER BY order_date
            ) AS prev_amount
        FROM orders
        WHERE order_date >= '2024-01-01'
    )
    SELECT customer_id, order_date, amount, prev_amount
    FROM ranked_orders
    WHERE rn <= 3
""")

# Zero-copy to Polars DataFrame
df_pl = results.pl()  # Returns Polars LazyFrame

df_eager = df_pl.collect()  # Trigger execution
print(df_eager.head(6))

Output:

shape: (6, 4)
┌─────────────┬────────────┬─────────┬────────────┐
│ customer_id ┆ order_date ┆ amount  ┆ prev_amount│
│ ---         ┆ ---        ┆ ---     ┆ ---        │
│ i32         ┆ date       ┆ f64     ┆ f64        │
╞═════════════╪════════════╪═════════╪════════════╡
│ 1000234     ┆ 2024-06-15 ┆ 320.50  ┆ 210.00     │
│ 1000234     ┆ 2024-05-02 ┆ 210.00  ┆ 180.75     │
│ 1000234     ┆ 2024-03-20 ┆ 180.75  ┆ NULL       │
│ 1000567     ┆ 2024-06-10 ┆ 290.00  ┆ 300.00     │
│ 1000567     ┆ 2024-04-15 ┆ 300.00  ┆ 280.00     │
│ 1000567     ┆ 2024-02-28 ┆ 280.00  ┆ NULL       │
└─────────────┴────────────┴─────────┴────────────┘

Use Case 3: Bi-directional Data Exchange Performance Benchmark

import time

# Prepare 5M row test data
print("Generating test data...")
test_df = pd.DataFrame({
    'id': range(5_000_000),
    'value': pd.np.random.randn(5_000_000),
    'category': pd.np.random.choice(['A', 'B', 'C'], 5_000_000),
})
con.sql("CREATE TABLE test_data AS SELECT * FROM test_df")

# Test 1: DuckDB → Pandas (df())
t0 = time.time()
result_pd = con.sql("SELECT * FROM test_data WHERE value > 0").df()
t1 = time.time()
print(f"DuckDB→Pandas: {t1-t0:.3f}s, shape={result_pd.shape}")

# Test 2: DuckDB → Polars (pl())
t0 = time.time()
result_pl = con.sql("SELECT * FROM test_data WHERE value > 0").pl().collect()
t1 = time.time()
print(f"DuckDB→Polars: {t1-t0:.3f}s, shape={result_pl.shape}")

# Test 3: Pandas → DuckDB (INSERT)
t0 = time.time()
con.sql("INSERT INTO test_data SELECT * FROM test_df")
t1 = time.time()
print(f"Pandas→DuckDB: {t1-t0:.3f}s")

# Test 4: Polars → DuckDB
t0 = time.time()
con.sql("INSERT INTO test_data SELECT * FROM result_pl")
t1 = time.time()
print(f"Polars→DuckDB:  {t1-t0:.3f}s")

Typical output:

Generating test data...
DuckDB→Pandas: 0.125s, shape=(2500000, 4)
DuckDB→Polars: 0.089s, shape=(2500000, 4)
Pandas→DuckDB: 0.342s
Polars→DuckDB:  0.215s

Conclusion: DuckDB → Polars is fastest (Polars natively supports Arrow). DuckDB → Pandas is also very efficient. Both directions use Arrow Columnar Format, avoiding traditional CSV intermediate format overhead.

Use Case 4: Hybrid Query Pattern — SQL Exploration + Python Iteration

In practice, we often use SQL for quick hypothesis validation, then Python for fine-tuning.

# Step 1: Quick data distribution exploration with SQL
distribution = con.sql("""
    SELECT 
        category,
        COUNT(*) AS cnt,
        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value) AS median,
        PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY value) AS p95
    FROM test_data
    GROUP BY category
""").df()
print(distribution)

# Step 2: Fine-grained calculation with Pandas based on SQL results
import numpy as np
for cat in distribution['category']:
    subset = con.sql(f"SELECT value FROM test_data WHERE category = '{cat}'").df()
    # Custom feature engineering in Pandas
    subset['log_value'] = np.log1p(subset['value'].abs())
    subset['is_outlier'] = subset['value'] > subset['value'].quantile(0.99)
    print(f"  {cat}: outliers={subset['is_outlier'].sum()}, mean_log={subset['log_value'].mean():.4f}")

Output:

  category  cnt      median        p95
0        A  1667234  0.001234  1.642891
1        B  1666389 -0.002156  1.638472
2        C  1666377  0.000891  1.651203
  A: outliers=16682, mean_log=0.8923
  B: outliers=16654, mean_log=0.8901
  C: outliers=16701, mean_log=0.8915

Use Case 5: Using Polars as DuckDB Input Source

When data is already in Polars, you can register it as a DuckDB table via register():

# Create Polars DataFrame
pl_df = pl.DataFrame({
    'product_id': [101, 102, 103, 104, 105],
    'price': [29.99, 49.99, 19.99, 99.99, 39.99],
    'stock': [150, 80, 200, 30, 120],
    'category': ['Electronics', 'Home', 'Books', 'Electronics', 'Fashion'],
})

# Register as DuckDB table
con.register('products', pl_df)

# Query Polars data with SQL
result = con.sql("""
    SELECT 
        category,
        AVG(price) AS avg_price,
        SUM(stock) AS total_stock
    FROM products
    GROUP BY category
    ORDER BY total_stock DESC
""").pl().collect()

print(result)

Output:

shape: (4, 3)
┌─────────────┬──────────┬─────────────┐
│ category    ┆ avg_price┆ total_stock │
│ ---         ┆ ---      ┆ ---         │
│ str         ┆ f64      ┆ i64         │
╞═════════════╪══════════╪═════════════╡
│ Books       ┆ 19.99    ┆ 200         │
│ Fashion     ┆ 39.99    ┆ 120         │
│ Electronics ┆ 64.99    ┆ 180         │
│ Home        ┆ 49.99    ┆ 80          │
└─────────────┴──────────┴─────────────┘

Performance Tuning Tips

ScenarioRecommended ApproachReason
Large CSV readsDuckDB read_csv_auto then .df()SQL filtering reduces memory
Multi-table joinsRegister all in DuckDB, use SQLVectorized execution + optimizer
Feature engineeringDuckDB aggregation → Pandas computationSQL efficient aggregation, Python flexible
Streaming processingDuckDB fetchmany() + Pandas batchControl memory peak
Polars priorityUse .pl() not .df()Zero-copy + LazyFrame deferred execution

Complete Code Example

import duckdb
import pandas as pd
import polars as pl
import numpy as np

# Initialize
con = duckdb.connect(":memory:")

# 1. Read CSV
con.sql("CREATE TABLE sales AS SELECT * FROM read_csv_auto('sales.csv')")

# 2. SQL aggregation
summary = con.sql("""
    SELECT 
        DATE_TRUNC('month', sale_date) AS month,
        category,
        SUM(amount) AS total_sales,
        COUNT(*) AS transactions
    FROM sales
    WHERE sale_date >= '2024-01-01'
    GROUP BY 1, 2
    ORDER BY 1, 2
""").df()

# 3. Register Polars data
products = pl.DataFrame({'id': [1,2,3], 'name': ['A','B','C']})
con.register('products', products)

# 4. Join query
enriched = con.sql("""
    SELECT s.*, p.name
    FROM summary s
    JOIN products p ON s.category = p.id
""").pl().collect()

print(f"Processed {len(enriched)} rows")

Summary

The DuckDB + Pandas/Polars combination is a best practice in data engineering:

  • DuckDB handles: Large-scale data reading, SQL filtering/aggregation, complex window functions
  • Pandas/Polars handle: Feature engineering, ML preprocessing, visualization
  • Arrow format: The bridge between them, enabling zero-copy data exchange

Master this combo, and you get SQL expressiveness with Python data ecosystem flexibility.

For more DuckDB practical tips, follow 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.