Menu

Data Engineering Topic

DuckDB: The Analytical Database for Local Analytics

What is DuckDB?

DuckDB is an in-process SQL OLAP database designed for analytical workloads. Think of it as "SQLite for analytics" - embedded, fast, and optimized for complex queries on large datasets.

Core Philosophy

  • Embedded: No separate server process, runs in your application

  • Columnar Storage: Optimized for analytical queries

  • Zero Configuration: Just import and use

  • High Performance: Vectorized query execution

  • Standard SQL: Full SQL support with analytical extensions

Key Features & Architecture

In-Process Architecture

text

┌─────────────────────────────────────────┐
│         Your Application Process        │
│                                         │
│  ┌─────────────────────────────────┐   │
│  │         DuckDB Engine           │   │
│  │                                 │   │
│  │  ┌─────────┐  ┌──────────────┐ │   │
│  │  │ Parser  │  │ Optimizer    │ │   │
│  │  └─────────┘  └──────────────┘ │   │
│  │                                 │   │
│  │  ┌─────────┐  ┌──────────────┐ │   │
│  │  │ Planner │  │ Executor     │ │   │
│  │  └─────────┘  └──────────────┘ │   │
│  │                                 │   │
│  │  ┌───────────────────────────┐  │   │
│  │  │   Columnar Storage Engine │  │   │
│  │  │    • Vectorized Execution  │  │   │
│  │  │    • Parallel Processing   │  │   │
│  │  └───────────────────────────┘  │   │
│  └─────────────────────────────────┘   │
│                                         │
│  Local Files:                          │
│  • Parquet, CSV, JSON                  │
│  • Arrow, Pandas DataFrames            │
│                                         │
└─────────────────────────────────────────┘

Performance Characteristics

python

import duckdb
import pandas as pd
import time

# Benchmark: DuckDB vs Pandas
df = pd.DataFrame({
    'id': range(1_000_000),
    'value': np.random.randn(1_000_000),
    'category': np.random.choice(['A', 'B', 'C', 'D'], 1_000_000)
})

# Pandas groupby
start = time.time()
pandas_result = df.groupby('category')['value'].agg(['mean', 'sum', 'count'])
print(f"Pandas: {time.time() - start:.2f}s")

# DuckDB groupby
start = time.time()
duckdb_result = duckdb.sql("""
    SELECT 
        category,
        AVG(value) as mean,
        SUM(value) as sum,
        COUNT(*) as count
    FROM df
    GROUP BY category
""").df()
print(f"DuckDB: {time.time() - start:.2f}s")

# Typically: DuckDB 5-100x faster for analytical queries

Installation & Basic Usage

python

# Installation
# pip install duckdb
# pip install duckdb pandas pyarrow

import duckdb
import pandas as pd
import numpy as np

# Basic connection (in-memory by default)
conn = duckdb.connect()  # Creates an in-memory database

# Or connect to a persistent file
conn = duckdb.connect('my_database.db')  # Creates/opens a .db file

# Execute SQL directly
conn.execute("CREATE TABLE users (id INTEGER, name VARCHAR, age INTEGER)")

# Insert data
conn.execute("INSERT INTO users VALUES (1, 'Alice', 30), (2, 'Bob', 25)")

# Query data
result = conn.execute("SELECT * FROM users").fetchall()
print(result)  # [(1, 'Alice', 30), (2, 'Bob', 25)]

# Query to pandas DataFrame
df = conn.execute("SELECT * FROM users").df()

Working with Different Data Sources

1. Direct File Queries

python

import duckdb

# Query Parquet files directly (no load needed)
result = duckdb.sql("""
    SELECT 
        customer_id,
        SUM(amount) as total_spent,
        COUNT(*) as transaction_count
    FROM 'sales_2024_*.parquet'  -- Wildcard pattern
    WHERE transaction_date >= '2024-01-01'
    GROUP BY customer_id
    HAVING total_spent > 1000
    ORDER BY total_spent DESC
""").df()

# Query CSV files
result = duckdb.sql("""
    SELECT *
    FROM 'data.csv'
    -- Automatic schema inference
    WHERE column1 > 100
""").df()

# Query JSON files
result = duckdb.sql("""
    SELECT 
        json->>'customer_id' as customer_id,
        json->>'amount'::DECIMAL as amount,
        json->>'timestamp'::TIMESTAMP as timestamp
    FROM read_json_auto('transactions.json')
""").df()

# Multiple file formats in one query
result = duckdb.sql("""
    SELECT * FROM 'sales.parquet'
    UNION ALL
    SELECT * FROM 'sales.csv'
    UNION ALL
    SELECT * FROM read_json_auto('sales.json')
""").df()

2. Integration with Pandas & Polars

python

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

# From Pandas DataFrame
df_pandas = pd.DataFrame({
    'id': [1, 2, 3],
    'name': ['Alice', 'Bob', 'Charlie'],
    'salary': [50000, 60000, 70000]
})

# Query pandas DataFrame directly
result = duckdb.sql("""
    SELECT 
        name,
        AVG(salary) as avg_salary
    FROM df_pandas
    GROUP BY name
""").df()

# From Polars DataFrame
df_polars = pl.DataFrame({
    'id': [1, 2, 3],
    'name': ['Alice', 'Bob', 'Charlie'],
    'salary': [50000, 60000, 70000]
})

# Query polars DataFrame directly (via arrow)
result = duckdb.sql("""
    SELECT * FROM df_polars
    WHERE salary > 55000
""").df()

# Create DuckDB table from pandas/polars
conn.execute("CREATE TABLE employees AS SELECT * FROM df_pandas")
# or
conn.execute("CREATE TABLE employees AS SELECT * FROM df_polars")

# Two-way data transfer
# Pandas → DuckDB → Polars
duckdb_result = duckdb.sql("SELECT * FROM df_pandas WHERE salary > 55000")
polars_df = pl.from_arrow(duckdb_result.arrow())  # Fast zero-copy conversion

3. Apache Arrow Integration

python

import duckdb
import pyarrow as pa
import pyarrow.dataset as ds

# Create Arrow Table
table = pa.table({
    'id': [1, 2, 3],
    'value': [10.5, 20.3, 30.7]
})

# Query Arrow Table directly
result = duckdb.sql("""
    SELECT 
        id,
        value * 1.1 as value_with_tax
    FROM table
""").arrow()  # Returns Arrow table

# Arrow Datasets (directory of Parquet files)
dataset = ds.dataset("sales_data/", format="parquet")
result = duckdb.sql("""
    SELECT 
        EXTRACT(YEAR FROM sale_date) as year,
        SUM(amount) as yearly_revenue
    FROM dataset
    GROUP BY 1
""").df()

# Zero-copy between DuckDB and Arrow
# Arrow → DuckDB
duckdb.register('sales_arrow', table)
# DuckDB → Arrow
arrow_result = duckdb.sql("SELECT * FROM sales_arrow").arrow()

4. Spatial Data with DuckDB

python

import duckdb
from shapely.geometry import Point, Polygon
import geopandas as gpd

# Enable spatial extension
conn.execute("INSTALL spatial")
conn.execute("LOAD spatial")

# Create spatial table
conn.execute("""
    CREATE TABLE locations AS 
    SELECT 
        1 as id,
        ST_Point(-74.006, 40.7128) as geometry,
        'New York' as name
    UNION ALL
    SELECT 
        2 as id,
        ST_Point(-118.2437, 34.0522) as geometry,
        'Los Angeles' as name
""")

# Spatial queries
result = conn.execute("""
    SELECT 
        name,
        ST_AsText(geometry) as location,
        ST_Distance(
            geometry,
            ST_Point(-122.4194, 37.7749)
        ) as distance_from_sf
    FROM locations
    WHERE ST_DWithin(
        geometry,
        ST_Point(-122.4194, 37.7749),
        1000000  -- Within 1000km
    )
""").df()

# Load from GeoPackage/Shapefile
conn.execute("""
    CREATE TABLE countries AS
    SELECT * FROM ST_Read('countries.gpkg')
""")

# Spatial aggregations
result = conn.execute("""
    SELECT 
        continent,
        COUNT(*) as country_count,
        SUM(ST_Area(geometry)) as total_area,
        ST_Union_Agg(geometry) as combined_geometry
    FROM countries
    GROUP BY continent
""").df()

Advanced Features

1. Window Functions

python

import duckdb

# Complex window functions
result = duckdb.sql("""
    WITH sales AS (
        SELECT 
            customer_id,
            transaction_date,
            amount,
            product_category,
            region
        FROM 'sales.parquet'
        WHERE transaction_date >= '2024-01-01'
    ),
    ranked_sales AS (
        SELECT 
            customer_id,
            transaction_date,
            amount,
            product_category,
            region,
            -- Running total per customer
            SUM(amount) OVER (
                PARTITION BY customer_id 
                ORDER BY transaction_date
                ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
            ) as cumulative_amount,
            
            -- Rank transactions within customer
            ROW_NUMBER() OVER (
                PARTITION BY customer_id 
                ORDER BY amount DESC
            ) as transaction_rank,
            
            -- Moving average (7-day window)
            AVG(amount) OVER (
                PARTITION BY customer_id, product_category
                ORDER BY transaction_date
                RANGE BETWEEN INTERVAL 7 DAYS PRECEDING 
                      AND CURRENT ROW
            ) as moving_avg_7day,
            
            -- Percent of category total
            amount * 100.0 / SUM(amount) OVER (
                PARTITION BY product_category
            ) as category_percentage,
            
            -- Compare to customer average
            amount - AVG(amount) OVER (
                PARTITION BY customer_id
            ) as diff_from_customer_avg
            
        FROM sales
    )
    SELECT *
    FROM ranked_sales
    WHERE transaction_rank <= 5  -- Top 5 transactions per customer
""").df()

2. Time Series Analysis

python

import duckdb
import pandas as pd

# Create time series data
dates = pd.date_range('2024-01-01', '2024-03-31', freq='D')
df = pd.DataFrame({
    'date': dates,
    'value': np.random.randn(len(dates)).cumsum() + 100
})

result = duckdb.sql("""
    WITH time_series AS (
        SELECT 
            date,
            value,
            -- Date parts
            EXTRACT(YEAR FROM date) as year,
            EXTRACT(MONTH FROM date) as month,
            EXTRACT(DAY FROM date) as day,
            EXTRACT(DOW FROM date) as day_of_week,
            
            -- Time-based calculations
            value - LAG(value, 1) OVER (ORDER BY date) as daily_change,
            value - LAG(value, 7) OVER (ORDER BY date) as weekly_change,
            
            -- Moving statistics
            AVG(value) OVER (
                ORDER BY date
                ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
            ) as weekly_moving_avg,
            
            STDDEV(value) OVER (
                ORDER BY date
                ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
            ) as monthly_volatility,
            
            -- Seasonal decomposition (simplified)
            value - AVG(value) OVER (
                PARTITION BY EXTRACT(DOW FROM date)
                ORDER BY date
                ROWS BETWEEN 4 PRECEDING AND 4 FOLLOWING
            ) as deseasonalized
            
        FROM df
    ),
    aggregated AS (
        SELECT 
            year,
            month,
            -- Business metrics
            MIN(value) as monthly_low,
            MAX(value) as monthly_high,
            AVG(value) as monthly_avg,
            SUM(daily_change) as net_monthly_change,
            -- Volatility
            STDDEV(daily_change) as daily_volatility,
            -- Trend
            CORR(date::INTEGER, value) as trend_correlation
        FROM time_series
        GROUP BY year, month
    )
    SELECT *
    FROM aggregated
    ORDER BY year, month
""").df()

3. Machine Learning with DuckDB

python

import duckdb
import pandas as pd
from sklearn.datasets import make_classification

# Enable ML extensions
conn.execute("INSTALL httpfs")  # For loading datasets
conn.execute("LOAD httpfs")

# Load sample data directly from URL
conn.execute("""
    CREATE TABLE iris AS
    SELECT * FROM read_csv_auto('https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv')
""")

# Feature engineering in SQL
features = conn.execute("""
    WITH prepared_data AS (
        SELECT 
            species,
            sepal_length,
            sepal_width,
            petal_length,
            petal_width,
            -- Create features
            sepal_length * sepal_width as sepal_area,
            petal_length * petal_width as petal_area,
            sepal_length / NULLIF(sepal_width, 0) as sepal_ratio,
            petal_length / NULLIF(petal_width, 0) as petal_ratio,
            -- Statistical features
            (sepal_length - AVG(sepal_length) OVER()) / 
                NULLIF(STDDEV(sepal_length) OVER(), 0) as sepal_length_z
        FROM iris
    ),
    aggregated AS (
        SELECT 
            species,
            AVG(sepal_length) as avg_sepal_length,
            AVG(petal_length) as avg_petal_length,
            COUNT(*) as count,
            -- Percentiles
            PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sepal_width) 
                as median_sepal_width
        FROM iris
        GROUP BY species
    )
    SELECT * FROM prepared_data
""").df()

# Train-test split in SQL
train_test = conn.execute("""
    WITH numbered AS (
        SELECT *,
               ROW_NUMBER() OVER () as row_num
        FROM iris
    )
    SELECT 
        *,
        CASE 
            WHEN row_num % 5 = 0 THEN 'test'
            ELSE 'train'
        END as dataset
    FROM numbered
""").df()

# Export for ML
train_data = train_test[train_test['dataset'] == 'train']
test_data = train_test[train_test['dataset'] == 'test']

# Or use DuckDB's built-in MADlib-like functions
conn.execute("""
    CREATE TABLE predictions AS
    SELECT 
        species,
        sepal_length,
        -- Simple linear regression prediction
        (sepal_length * 0.5 + petal_length * 0.3 + 0.2) as predicted_value
    FROM iris
""")

Local Analytics Use Cases

1. Exploratory Data Analysis (EDA)

python

class LocalEDA:
    """Perform EDA entirely locally with DuckDB"""
    
    def __init__(self, data_path):
        self.conn = duckdb.connect()
        self.data_path = data_path
    
    def load_data(self):
        """Load data from various sources"""
        # Auto-detect format and load
        self.conn.execute(f"""
            CREATE TABLE raw_data AS 
            SELECT * FROM read_auto('{self.data_path}')
        """)
        
        # Or explicitly
        if self.data_path.endswith('.parquet'):
            self.conn.execute(f"""
                CREATE TABLE raw_data AS 
                SELECT * FROM read_parquet('{self.data_path}')
            """)
        elif self.data_path.endswith('.csv'):
            self.conn.execute(f"""
                CREATE TABLE raw_data AS 
                SELECT * FROM read_csv_auto('{self.data_path}')
            """)
    
    def basic_statistics(self):
        """Compute comprehensive statistics"""
        stats = self.conn.execute("""
            WITH column_stats AS (
                SELECT 
                    column_name,
                    data_type,
                    COUNT(*) as non_null_count,
                    SUM(CASE WHEN column_value IS NULL THEN 1 ELSE 0 END) 
                        as null_count,
                    AVG(CASE WHEN data_type IN ('INTEGER', 'DOUBLE') 
                          THEN column_value::DOUBLE END) as mean_value,
                    STDDEV(CASE WHEN data_type IN ('INTEGER', 'DOUBLE') 
                           THEN column_value::DOUBLE END) as std_value,
                    MIN(column_value) as min_value,
                    MAX(column_value) as max_value,
                    COUNT(DISTINCT column_value) as distinct_count
                FROM (
                    SELECT 
                        UNNEST(['col1', 'col2', 'col3']) as column_name,
                        UNNEST(['INTEGER', 'VARCHAR', 'DOUBLE']) as data_type
                ) cols
                CROSS JOIN raw_data
                GROUP BY column_name, data_type
            ),
            distribution AS (
                SELECT 
                    column_name,
                    column_value,
                    COUNT(*) as frequency,
                    COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (PARTITION BY column_name) 
                        as percentage
                FROM (
                    SELECT 
                        UNNEST(['col1', 'col2']) as column_name,
                        UNNEST([col1, col2]) as column_value
                    FROM raw_data
                )
                WHERE column_value IS NOT NULL
                GROUP BY column_name, column_value
            )
            SELECT * FROM column_stats
            ORDER BY null_count DESC
        """).df()
        
        return stats
    
    def correlation_analysis(self):
        """Compute correlations between numeric columns"""
        correlations = self.conn.execute("""
            WITH numeric_data AS (
                SELECT 
                    col1::DOUBLE as x,
                    col2::DOUBLE as y,
                    col3::DOUBLE as z
                FROM raw_data
                WHERE col1 IS NOT NULL 
                  AND col2 IS NOT NULL 
                  AND col3 IS NOT NULL
            ),
            correlations AS (
                SELECT 
                    'col1 vs col2' as pair,
                    CORR(x, y) as correlation,
                    COUNT(*) as sample_size
                FROM numeric_data
                UNION ALL
                SELECT 
                    'col1 vs col3' as pair,
                    CORR(x, z) as correlation,
                    COUNT(*) as sample_size
                FROM numeric_data
                UNION ALL
                SELECT 
                    'col2 vs col3' as pair,
                    CORR(y, z) as correlation,
                    COUNT(*) as sample_size
                FROM numeric_data
            )
            SELECT 
                pair,
                ROUND(correlation, 3) as correlation,
                sample_size,
                CASE 
                    WHEN ABS(correlation) > 0.7 THEN 'Strong'
                    WHEN ABS(correlation) > 0.3 THEN 'Moderate'
                    ELSE 'Weak'
                END as strength
            FROM correlations
            ORDER BY ABS(correlation) DESC
        """).df()
        
        return correlations
    
    def anomaly_detection(self):
        """Detect anomalies using statistical methods"""
        anomalies = self.conn.execute("""
            WITH stats AS (
                SELECT 
                    AVG(value) as mean,
                    STDDEV(value) as std,
                    AVG(value) - 3 * STDDEV(value) as lower_bound,
                    AVG(value) + 3 * STDDEV(value) as upper_bound
                FROM raw_data
            ),
            anomalies AS (
                SELECT 
                    r.*,
                    s.mean,
                    s.std,
                    CASE 
                        WHEN r.value < s.lower_bound THEN 'LOW_OUTLIER'
                        WHEN r.value > s.upper_bound THEN 'HIGH_OUTLIER'
                        ELSE 'NORMAL'
                    END as anomaly_type,
                    ABS(r.value - s.mean) / NULLIF(s.std, 0) as z_score
                FROM raw_data r
                CROSS JOIN stats s
                WHERE r.value < s.lower_bound 
                   OR r.value > s.upper_bound
            )
            SELECT *
            FROM anomalies
            ORDER BY z_score DESC
        """).df()
        
        return anomalies
    
    def generate_report(self):
        """Generate comprehensive EDA report"""
        import matplotlib.pyplot as plt
        import seaborn as sns
        
        # Get statistics
        stats = self.basic_statistics()
        correlations = self.correlation_analysis()
        
        # Create visualizations
        fig, axes = plt.subplots(2, 2, figsize=(12, 10))
        
        # Distribution plots
        for i, column in enumerate(['col1', 'col2', 'col3', 'col4'][:4]):
            ax = axes[i // 2, i % 2]
            data = self.conn.execute(f"""
                SELECT {column} 
                FROM raw_data 
                WHERE {column} IS NOT NULL
                LIMIT 1000
            """).df()
            
            if pd.api.types.is_numeric_dtype(data[column]):
                ax.hist(data[column], bins=30, edgecolor='black')
                ax.set_title(f'Distribution of {column}')
            else:
                value_counts = data[column].value_counts().head(10)
                ax.bar(value_counts.index, value_counts.values)
                ax.set_title(f'Top 10 values in {column}')
                ax.tick_params(axis='x', rotation=45)
        
        plt.tight_layout()
        plt.savefig('eda_distributions.png')
        
        # Generate markdown report
        report = f"""
        # Exploratory Data Analysis Report
        
        ## Dataset Overview
        - Total rows: {len(self.conn.execute("SELECT COUNT(*) FROM raw_data").fetchone()[0])}
        - Total columns: {len(stats)}
        
        ## Column Statistics
        {stats.to_markdown()}
        
        ## Correlations
        {correlations.to_markdown()}
        
        ## Missing Values
        Missing values by column:
        {(stats['null_count'] / (stats['non_null_count'] + stats['null_count']) * 100).round(2).to_markdown()}
        
        ## Visualizations
        ![Distributions](eda_distributions.png)
        """
        
        with open('eda_report.md', 'w') as f:
            f.write(report)

 

Comments (0)

No comments yet. Be the first to comment!

LecturreAI Assistant

Hello! I'm LecturreAI

What would you like to learn today?