Featured image of post Build a Personal Data Product Backend with DuckDB in 30 Lines of Code

Build a Personal Data Product Backend with DuckDB in 30 Lines of Code

Build an e-commerce sales analysis API with DuckDB + FastAPI. Multi-user isolation, query-based billing, and a complete path from MVP to monetization.

Build a Personal Data Product Backend with DuckDB in 30 Lines of Code

Many people want to build data products with DuckDB but get stuck on architecture design. Today I’ll walk through a complete case study: building an “e-commerce sales analysis API” using DuckDB + FastAPI, with multi-user isolation and query-based billing.

1. Why This Direction Makes Money

Imagine you get a request: provide a daily sales data API for a small merchant with 10 stores generating ~500K sales records per day.

Traditional approach: MySQL + scheduled ETL + cloud server. Development cycle: 2 weeks. Monthly ops cost: $70+.

DuckDB approach: Query Parquet files directly with SQL. Zero ETL. Millisecond responses. A single machine handles it.

The key insight: DuckDB is a columnar analytical engine optimized for “read-heavy, write-light” scenarios. Your data product is essentially a read-only analytics service — exactly DuckDB’s sweet spot.

2. Core Architecture

Parquet Files (partitioned by day)
        ↓
DuckDB In-Memory Database (scans Parquet directly)
        ↓
FastAPI Routes
        ↓
Paid User API Calls

Key design points:

  1. Parquet for storage: Columnar format, high compression, DuckDB native support
  2. DuckDB reads Parquet directly: No database import needed, zero data copying
  3. FastAPI as interface layer: Lightweight, async, auto-generated OpenAPI docs
  4. Per-store data isolation: Each client gets their own storage, natural data separation

3. Step 1: Prepare Test Data

Generate simulated data with Python to understand the schema:

import duckdb
import pandas as pd
import numpy as np
from datetime import datetime, timedelta

def generate_daily_sales(days=30):
    """Generate 30 days of simulated sales data"""
    np.random.seed(42)
    stores = [f"store_{i:03d}" for i in range(1, 11)]
    products = [f"prod_{i:03d}" for i in range(1, 51)]
    
    records = []
    for day_offset in range(days):
        date = datetime.now() - timedelta(days=day_offset)
        n_records = np.random.randint(15000, 25000)
        
        df = pd.DataFrame({
            'date': np.random.choice(
                pd.date_range(date, periods=1), n_records
            ),
            'store_id': np.random.choice(stores, n_records),
            'product_id': np.random.choice(products, n_records),
            'quantity': np.random.randint(1, 20, n_records),
            'unit_price': np.round(np.random.uniform(10, 500, n_records), 2),
            'region': np.random.choice(['CN', 'US', 'EU'], n_records, p=[0.6, 0.3, 0.1])
        })
        df['revenue'] = df['quantity'] * df['unit_price']
        records.append(df)
    
    return pd.concat(records, ignore_index=True)

# Generate 1.5M rows of simulated data
df = generate_daily_sales(30)
print(f"Generated {len(df)} rows")
print(df.head())

Data schema:

  • date: Sale date
  • store_id: Store identifier
  • product_id: Product identifier
  • quantity: Units sold
  • unit_price: Price per unit
  • region: Region (CN/US/EU)
  • revenue: Revenue (quantity × price)

4. Step 2: Build the Query Engine

DuckDB’s killer feature: direct SQL queries on Parquet without loading into memory:

import duckdb

class SalesAnalyticsEngine:
    def __init__(self, parquet_path="s3://bucket/sales/"):
        self.path = parquet_path
        self.con = duckdb.connect("memory:")
        # Scan Parquet directly, zero data copy
        self.con.execute(f"""
            CREATE TABLE sales AS 
            SELECT * FROM read_parquet('{parquet_path}*.parquet')
        """)
    
    def daily_revenue(self, store_id: str, days: int = 30) -> list:
        """Daily revenue for a store over N days"""
        result = self.con.execute(f"""
            SELECT date::DATE as dt,
                   SUM(revenue) as revenue,
                   SUM(quantity) as orders
            FROM sales 
            WHERE store_id = '{store_id}'
              AND date >= CURRENT_DATE - INTERVAL '{days} DAYS'
            GROUP BY dt
            ORDER BY dt DESC
            LIMIT {days}
        """).fetchall()
        return [{"date": r[0].isoformat(), "revenue": r[1], "orders": r[2]} for r in result]
    
    def top_products(self, store_id: str, n: int = 10) -> list:
        """Top N best-selling products"""
        result = self.con.execute(f"""
            SELECT product_id,
                   SUM(quantity) as total_qty,
                   SUM(revenue) as total_rev,
                   COUNT(DISTINCT date) as active_days
            FROM sales 
            WHERE store_id = '{store_id}'
              AND date >= CURRENT_DATE - INTERVAL '30 DAYS'
            GROUP BY product_id
            ORDER BY total_rev DESC
            LIMIT {n}
        """).fetchall()
        return [{"product_id": r[0], "qty": r[1], "revenue": r[2], "days": r[3]} for r in result]
    
    def regional_breakdown(self, store_id: str) -> dict:
        """Regional sales breakdown"""
        result = self.con.execute(f"""
            SELECT region,
                   COUNT(*) as txn_count,
                   SUM(revenue) as revenue,
                   AVG(unit_price) as avg_price
            FROM sales 
            WHERE store_id = '{store_id}'
            GROUP BY region
        """).fetchall()
        return {r[0]: {"txns": r[1], "revenue": r[2], "avg_price": r[3]} for r in result}

Key understanding: read_parquet() doesn’t load data into memory — it scans Parquet file metadata and reads only the columns you need. This means even a 10GB Parquet file might only use tens of MB of RAM.

5. Step 3: Wrap as REST API

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
import uvicorn

app = FastAPI(title="E-commerce Sales Analysis API")
engine = SalesAnalyticsEngine()

@app.middleware("http")
async def auth_middleware(request, call_next):
    """Simple API Key authentication"""
    api_key = request.headers.get("X-API-Key")
    if api_key != "your-premium-key":
        raise HTTPException(status_code=401)
    return await call_next(request)

@app.get("/api/v1/{store_id}/daily")
async def get_daily(store_id: str, days: int = 30):
    return {"store_id": store_id, "data": engine.daily_revenue(store_id, days)}

@app.get("/api/v1/{store_id}/top-products")
async def get_top_products(store_id: str, n: int = 10):
    return {"store_id": store_id, "data": engine.top_products(store_id, n)}

@app.get("/api/v1/{store_id}/regional")
async def get_regional(store_id: str):
    return {"store_id": store_id, "data": engine.regional_breakdown(store_id)}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

Visit http://localhost:8000/docs to see the auto-generated API documentation.

6. Performance Comparison: DuckDB vs Traditional Approach

DimensionDuckDB + ParquetMySQL + ETL
Dev cycle1-2 days1-2 weeks
Data storage costLocal/S3, nearly zeroCloud server + DB instance
Query response (500K rows)< 50ms100-500ms
Ops complexityLow (no DB service)High (maintain MySQL)
Scaling pathSeamless to DuckDB CloudRequires re-architecture
Monthly ops cost~$14 (server)~$70+

7. Advanced: Add a Cache Layer

For high-frequency queries, add a lightweight Redis cache:

import json, redis

r = redis.Redis(host='localhost', port=6379, db=0)

def get_with_cache(store_id: str, query_type: str, ttl: int = 300):
    key = f"sales:{store_id}:{query_type}"
    cached = r.get(key)
    if cached:
        return json.loads(cached)
    
    if query_type == "daily":
        data = engine.daily_revenue(store_id)
    elif query_type == "top_products":
        data = engine.top_products(store_id)
    else:
        data = engine.regional_breakdown(store_id)
    
    r.setex(key, ttl, json.dumps(data))
    return data

A 5-minute TTL means: queries for the same store within 5 minutes return from cache directly, and DuckDB only runs the query on first access or cache miss.

8. From MVP to Launch

  1. Day 1: Build API + basic queries
  2. Day 2: Add authentication and rate limiting
  3. Day 3: Add monitoring (DuckDB’s built-in duckdb.query_stats() shows execution plans)
  4. Day 4-7: Find 3-5 seed users for free trial, collect feedback, then launch

9. Monetization Strategy

This data product costs about 2-3 days to build once. Monthly subscription pricing: $14-40 per store. With 100 stores, that’s $1,400-4,000/month.

Tiered pricing:

  • Basic ($14/month): Daily revenue trends + TOP 10 products
  • Pro ($28/month): All basic features + regional analysis + 1,000 API calls/month
  • Enterprise ($40/month): All features + custom queries + 5,000 API calls/month

Customer acquisition channels:

  1. Post DuckDB tutorials on social media, drive traffic to private channels
  2. Share DuckDB case studies on Twitter/X
  3. Post in indie developer communities (Indie Hackers, Hacker News)

The full version with more detailed steps and additional cases is available at 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.