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.

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:
| Dimension | Traditional (Pandas + MySQL) | DuckDB Approach |
|---|---|---|
| Multi-CSV reading | Loop read_csv() + concat | read_csv_auto('dir/*.csv') — one line |
| Memory usage | All data in RAM at once | Streaming, computed on demand |
| Complex aggregation | Verbose Python code | Pure SQL CTE + window functions |
| Export formats | Manual conversion required | COPY ... TO direct export |
| Dependencies | pandas + 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:
- poi_counts: Group by district and category, count POIs and unique merchants
- district_stats: Compute comprehensive metrics per district including competition intensity (ratio of top-heavy districts)
- 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
- Near-zero marginal cost: Once the pipeline runs, generating data for N cities takes seconds — no manual intervention needed
- Re-sellable data products: One dataset can be sold as CSV, JSON, or API in three formats to different customers
- No server dependency: Runs entirely locally with DuckDB — zero cloud database costs
- 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:
| Model | Pricing Strategy | Target Customer |
|---|---|---|
| One-time data purchase | $15-40 per city | Small business owners, independent consultants |
| Monthly subscription | $30/month | Chain brands, investment analysts |
| API service | Per-call pricing | SaaS platforms, development teams |
| Customized reports | $70-300 per report | Consulting firms, investment agencies |
Step-by-step launch plan
- Week 1: Build the pipeline with DuckDB, generate baseline data for Beijing, Shanghai, Shenzhen
- Week 2: List data products on marketplace platforms (Taobao, Etsy-style data markets)
- Weeks 3-4: Collect customer feedback, refine metrics, expand to more cities
- Month 2: Integrate scheduled updates, launch subscription service
- 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
parquetwrite 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