Featured image of post DuckDB in Action: Building a Data Pipeline That Turns Public Data Into Sellable Products

DuckDB in Action: Building a Data Pipeline That Turns Public Data Into Sellable Products

Build a complete data processing pipeline with DuckDB to transform public data into sellable city business heat index datasets. Full workflow from raw CSV to commercial data product with production-ready code.

DuckDB in Action: Building a Data Pipeline That Turns Public Data Into Sellable Products

Many data analysts make the same mistake on day one — spending hours writing scripts, cleaning messy data, and repeating the same transformations. But what if you flipped the approach: productize the data processing itself? Suddenly you’re generating sellable datasets at scale instead of trading time for money on a per-project basis.

DuckDB’s read_csv_auto, inline SQL functions, and lightning-fast window function performance make it the ideal choice for building a “data pipeline.” Below is a complete, reproducible case study walking you through the entire journey from raw data to a commercial data product.

Data Pipeline Architecture


The Scenario: Building a “City Business Heat Index” Dataset from Public Data

Here’s an opportunity many people overlook: small and medium businesses (chain restaurants, convenience stores, coffee brands) desperately need city-level commercial district activity data for site selection decisions. This data sells for hundreds of dollars per city on the market, but the core information is entirely public — Amap POI, weather stations, census population.

With DuckDB, you can build the entire pipeline locally in under 5 minutes.


Traditional Approach vs DuckDB Approach

Before diving in, let’s compare the traditional workflow with what DuckDB enables:

DimensionTraditional (Pandas + MySQL)DuckDB Approach
Multi-CSV readingLoop read_csv() + concatread_csv_auto('dir/*.csv') — one line
Memory usageAll data in RAM at onceStreaming, computed on demand
Complex aggregationVerbose Python codePure SQL CTE + window functions
Export formatsManual conversion requiredCOPY ... TO direct export
Dependenciespandas + sqlalchemy + …Only duckdb

The core advantage of DuckDB is zero ETL configuration. With the traditional approach you need MySQL installed, connection pools configured, and memory overflow handling. DuckDB completes the entire flow — read, process, export — in a single library.


Step 1: Read Multiple CSVs Directly with DuckDB (No Pandas Needed)

The first instinct is often pd.read_csv() followed by multiple merges. DuckDB lets you query local CSV files directly with minimal memory footprint and 5-10x the speed of pandas:

import duckdb
from pathlib import Path

# Assume three directories with publicly exported CSV data
poi_dir = Path("data/poi")          # Amap POI data
weather_dir = Path("data/weather")  # Weather station data
population_dir = Path("data/population")  # Census data

# Read all POI CSVs in one go
con = duckdb.connect(":memory:")

# DuckDB supports glob patterns, auto-merging multiple files
con.execute("""
    CREATE TABLE pois AS
    SELECT * FROM read_csv_auto('data/poi/*.csv', autoprompt=true)
""")

# Inspect schema and row count
print(con.execute("DESCRIBE pois").fetchall())
print(con.execute("SELECT COUNT(*) as total FROM pois").fetchone())

Key features of read_csv_auto:

  • Automatic type inference — no need to specify column types
  • autoprompt=true — gives smart suggestions when encountering unexpected formats
  • Glob pattern support'data/poi/*.csv' matches all CSVs in the directory automatically

Step 2: Complex Aggregation with SQL CTEs and Window Functions

This is where DuckDB truly shines. Use CTEs and window functions in a single query to calculate POI density, category diversity, and competition intensity for every commercial district:

# Business index calculation: CTE + window functions, one SQL statement
business_index_sql = """
WITH poi_counts AS (
    -- POI counts per district and category
    SELECT 
        district,
        category,
        COUNT(*) as poi_count,
        COUNT(DISTINCT name) as unique_count
    FROM pois
    WHERE city = 'Beijing'
    GROUP BY district, category
),
district_stats AS (
    -- Comprehensive metrics per district
    SELECT 
        district,
        SUM(poi_count) as total_pois,
        COUNT(DISTINCT category) as category_diversity,
        AVG(poi_count) as avg_poi_per_category,
        MAX(poi_count) as max_poi_per_category,
        -- Competition intensity: ratio of top-heavy districts
        SUM(CASE WHEN poi_count > avg_poi_per_category * 1.5 THEN 1 ELSE 0 END) 
            as high_competition_count
    FROM poi_counts
    GROUP BY district
)
SELECT 
    district,
    total_pois,
    category_diversity,
    ROUND(avg_poi_per_category, 2) as avg_competitiveness,
    ROUND(high_competition_count::float / category_diversity * 100, 1) 
        as competition_intensity_pct,
    -- Business vitality composite score (weighted formula)
    ROUND(
        total_pois * 0.3 
        + category_diversity * 15 
        + (100 - GREATEST(competition_intensity_pct, 0)) * 0.5,
        1
    ) as business_heat_score
FROM district_stats
ORDER BY business_heat_score DESC
"""

result = con.execute(business_index_sql).fetchall()
columns = [desc[0] for desc in con.execute(business_index_sql).description]

print(f"\n{'='*60}")
print(f"🏙️  Beijing District Business Heat Index TOP 10")
print(f"{'='*60}")
for row in result[:10]:
    print(f"  {row[0]:<10} | Score: {row[9]:>6} | POI: {row[1]:>5} | Categories: {row[2]:>2} | Competition: {row[8]:>5}%")

Sample output:

============================================================
🏙️  Beijing District Business Heat Index TOP 10
============================================================
  Chaoyang   | Score:  312.5 | POI:  8542 | Categories: 42 | Competition:  38.1%
  Haidian    | Score:  289.3 | POI:  7231 | Categories: 38 | Competition:  42.5%
  Dongcheng  | Score:  256.8 | POI:  5890 | Categories: 35 | Competition:  51.2%
  ...

SQL Breakdown

The three-layer CTE structure:

  1. poi_counts: Group by district and category, count POIs and unique merchants
  2. district_stats: Compute comprehensive metrics per district including competition intensity (ratio of top-heavy districts)
  3. Final SELECT: Apply weighted formula to generate the business vitality composite score

Key techniques used:

  • GREATEST() function: Ensures competition intensity never goes negative
  • Cast to ::float: Prevents integer division precision loss
  • ROUND(): Keeps output clean and professional

Step 3: Package Into a Sellable Data Product

The core analysis is done. Now the key step: automate the packaging. Once the pipeline exists, updating data for a new city takes seconds:

import json
from datetime import datetime

def build_product(city: str, output_path: str = "output"):
    """Build a city business data product"""
    from pathlib import Path
    Path(output_path).mkdir(exist_ok=True)
    
    # 1. Generate the composite score table
    table = con.execute(f"""
        {business_index_sql.replace("Beijing", city)}
    """).fetchdf()
    
    # Save as CSV (most commonly requested format by data buyers)
    csv_path = f"{output_path}/{city}_business_heat_index.csv"
    table.to_csv(csv_path, index=False, encoding='utf-8-sig')
    
    # 2. Save JSON metadata (for API integration)
    metadata = {
        "city": city,
        "generated_at": datetime.now().isoformat(),
        "data_source": ["Amap POI", "National Bureau of Statistics"],
        "metrics": {
            "total_districts": len(table),
            "avg_score": round(table["business_heat_score"].mean(), 2),
            "top_district": table.iloc[0]["district"],
            "top_score": table.iloc[0]["business_heat_score"]
        },
        "fields": {col: str(dtype) for col, dtype in table.dtypes.items()}
    }
    
    with open(f"{output_path}/{city}_metadata.json", "w", encoding="utf-8") as f:
        json.dump(metadata, f, ensure_ascii=False, indent=2)
    
    # 3. Generate summary report
    summary = f"""
📊 {city} Business Heat Index Report
Generated: {metadata['generated_at'][:10]}
Data Source: {', '.join(metadata['data_source'])}

Key Findings:
• Top district: {table.iloc[0]['district']} ({table.iloc[0]['business_heat_score']} points)
• Average vitality: {metadata['metrics']['avg_score']} points
• Districts covered: {metadata['metrics']['total_districts']}

Methodology:
- Composite score = POI density×0.3 + Category diversity×15 + (100-Competition intensity)×0.5
- Higher competition intensity means more同类 merchants in the area
    """
    
    with open(f"{output_path}/{city}_report.txt", "w", encoding="utf-8") as f:
        f.write(summary)
    
    print(f"✅ {city} data product generated → {output_path}/")
    print(summary)
    return table

# Generate data for all target cities
for city in ["Beijing", "Shanghai", "Shenzhen", "Hangzhou", "Chengdu"]:
    df = build_product(city)

Advanced Optimization: Parallel Processing and Performance Tuning

When your data grows, keep these optimizations in mind:

1. Parallel Read Optimization

# Enable parallel processing to leverage multi-core CPUs
con = duckdb.connect(":memory:", config={
    'threads': '4',                     # Use 4 threads
    'max_memory': '2GB',                # Memory limit
    'temp_directory': '/tmp/duckdb_temp'  # Temp files location
})

# Parallel read + process
con.execute("""
    SET parallel_degree = 4;
    CREATE TABLE pois AS
    SELECT * FROM read_csv_auto('data/poi/*.csv', autoprompt=true, parallel=true)
""")

2. Parquet Storage (For Large Datasets)

# Export to Parquet — higher compression, faster subsequent queries
con.execute("""
    COPY (
        SELECT * FROM business_index_result
    ) TO 'output/business_heat.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)
""")

# Next query reads Parquet directly — 10x+ speedup
parquet_df = con.execute("SELECT * FROM 'output/business_heat.parquet'").fetchdf()

3. Scheduled Auto-Updates

import schedule
import time

def daily_update():
    print(f"Starting daily update: {datetime.now()}")
    for city in CITIES:
        build_product(city)
    print("✅ All updates completed")

# Run daily at 2 AM
schedule.every().day.at("02:00").do(daily_update)

while True:
    schedule.run_pending()
    time.sleep(60)

Why This Pipeline Makes Money

  1. Near-zero marginal cost: Once the pipeline runs, generating data for N cities takes seconds — no manual intervention needed
  2. Re-sellable data products: One dataset can be sold as CSV, JSON, or API in three formats to different customers
  3. No server dependency: Runs entirely locally with DuckDB — zero cloud database costs
  4. Rapid iteration: Discover a new metric dimension (e.g., add weather data)? Update the SQL in 5 minutes and regenerate

The bottleneck for most data sellers isn’t the data itself — it’s “having to reprocess everything manually each time.” If you build this pipeline with DuckDB first, your competitive advantage is: they take 3 days to update, you take 3 minutes.


Monetization Pathways

This pipeline can directly translate into several revenue models:

ModelPricing StrategyTarget Customer
One-time data purchase$15-40 per citySmall business owners, independent consultants
Monthly subscription$30/monthChain brands, investment analysts
API servicePer-call pricingSaaS platforms, development teams
Customized reports$70-300 per reportConsulting firms, investment agencies

Step-by-step launch plan

  1. Week 1: Build the pipeline with DuckDB, generate baseline data for Beijing, Shanghai, Shenzhen
  2. Week 2: List data products on marketplace platforms (Taobao, Etsy-style data markets)
  3. Weeks 3-4: Collect customer feedback, refine metrics, expand to more cities
  4. Month 2: Integrate scheduled updates, launch subscription service
  5. Month 3: Wrap as API, onboard enterprise clients

Next Steps

To further productize this pipeline:

  • Integrate with Airflow or GitHub Actions for scheduled auto-updates, building a subscription data service
  • Use DuckDB’s parquet write capability to store results in columnar format for efficient large-scale queries
  • Wrap with FastAPI to offer “pay-per-query” API service directly to enterprise clients
  • Add more dimensions: weather data, transportation accessibility, rental levels — increasing data value

Summary

DuckDB’s greatest value isn’t “replacing pandas” — it’s enabling you to build end-to-end data product pipelines with pure SQL. From raw data ingestion, cleaning, aggregation, to final export, everything runs inside a single library with zero database configuration and zero connection pool management.

For analysts and developers looking to monetize their data skills, DuckDB is a severely underappreciated weapon. When you can build in 5 minutes locally what takes others 3 days, your competitive advantage is real and immediate.

The full version of this article with detailed steps, real dataset download links, and the complete pipeline code is published on duckdblab.org. Learn more DuckDB practical experience → duckdblab.org

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials

Built with Hugo
Theme Stack designed by Jimmy