Featured image of post Build a Multi-Tenant Revenue Analytics SaaS with DuckDB — One Codebase, 10 Customers

Build a Multi-Tenant Revenue Analytics SaaS with DuckDB — One Codebase, 10 Customers

Use DuckDB's zero-config OLAP engine to build a multi-tenant automated reporting SaaS. Single-file database, per-shop data isolation, one codebase scaled across 10 e-commerce clients with near-zero marginal cost.

Build a Multi-Tenant Revenue Analytics SaaS with DuckDB — One Codebase, 10 Customers

A recurring request many freelance data analysts get: automatically generate daily revenue reports for e-commerce store owners. The common approach — Python scripts plus manual SQL — is slow and error-prone. Today, I’ll walk through building a lightweight, reusable multi-tenant automated reporting system with DuckDB that you can productize: one codebase, ten customers, near-zero marginal cost.

DuckDB Multi-Tenant Revenue Analytics SaaS Architecture

Why DuckDB for a SaaS Backend

Traditional approaches using Pandas + CSV hit walls with e-commerce order data: memory explosions, I/O bottlenecks, and sluggish execution at scale. DuckDB’s core advantages:

  • Single file = full database: One .duckdb file is a complete database, no services to start
  • Columnar storage + vectorized execution: 10-50x faster aggregations than Pandas on the same data
  • Natural multi-tenant isolation: One table per shop, data isolated within the same file
  • Zero operations: No PostgreSQL, MySQL, or any server database required

Project Structure

duck_revenue_saaS/
├── config.py
├── engine.py
├── report_generator.py
├── queries/
│   ├── daily_summary.sql
│   ├── category_breakdown.sql
│   └── refund_analysis.sql
├── templates/
│   └── report.html
└── main.py

Step 1: Engine Layer — Multi-Tenant Data Isolation

DuckDB’s CREATE INDEX uses compressed indexes that are more space-efficient than regular B-Trees and are maintained automatically.

# engine.py
import duckdb
from contextlib import contextmanager

class DuckRevenueEngine:
    """Multi-tenant DuckDB engine — one file manages all shops"""
    
    def __init__(self, db_path: str = "revenue.duckdb"):
        self.db_path = db_path
    
    @contextmanager
    def connection(self):
        conn = duckdb.connect(self.db_path, read_only=False)
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()
    
    def init_schema(self, shop_id: str):
        """Initialize table schema for each shop"""
        with self.connection() as conn:
            conn.execute(f"""
                CREATE TABLE IF NOT EXISTS {shop_id}_orders (
                    order_id VARCHAR PRIMARY KEY,
                    shop_id VARCHAR,
                    order_time TIMESTAMP,
                    customer_id VARCHAR,
                    amount DECIMAL(12, 2),
                    refund_amount DECIMAL(12, 2) DEFAULT 0,
                    status VARCHAR,
                    category VARCHAR,
                    channel VARCHAR
                )
            """)
            conn.execute(f"""
                CREATE INDEX IF NOT EXISTS idx_{shop_id}_time 
                ON {shop_id}_orders(order_time)
            """)
            conn.execute(f"""
                CREATE INDEX IF NOT EXISTS idx_{shop_id}_cust 
                ON {shop_id}_orders(customer_id)
            """)
            print(f"✅ Shop {shop_id} schema initialized")

Key design: All shops share the same DuckDB file, isolated by table name prefix. Benefits:

  1. Deployment is trivially simple — one file
  2. Cross-shop analysis works via direct JOIN, no distributed architecture needed
  3. Backup is a single file copy

Step 2: SQL Template Queries

Split SQL queries into independent .sql files using {{placeholder}} syntax, replaced in Python at runtime. This separates SQL from code, making maintenance and auditing straightforward.

-- queries/daily_summary.sql
SELECT 
    shop_id,
    DATE(order_time) AS order_date,
    COUNT(*) AS total_orders,
    SUM(amount - refund_amount) AS net_gmv,
    AVG(amount - refund_amount) AS avg_order_value,
    ROUND(
        SUM(refund_amount) * 100.0 / NULLIF(SUM(amount), 0),
        2
    ) AS refund_rate_pct
FROM {{shop_table}}
WHERE DATE(order_time) = DATE('{{date}}')
GROUP BY shop_id, DATE(order_time)
ORDER BY order_date DESC
-- queries/category_breakdown.sql
SELECT 
    category,
    COUNT(*) AS order_count,
    SUM(amount - refund_amount) AS category_gmv,
    ROUND(
        SUM(amount - refund_amount) * 100.0 / 
        NULLIF(SUM(SUM(amount - refund_amount)) OVER(), 0),
        2
    ) AS gmv_share_pct
FROM {{shop_table}}
WHERE DATE(order_time) = DATE('{{date}}')
GROUP BY category
ORDER BY category_gmv DESC
LIMIT 10
-- queries/refund_analysis.sql
SELECT 
    DATE(order_time) AS refund_date,
    order_id,
    amount,
    refund_amount,
    customer_id,
    category
FROM {{shop_table}}
WHERE DATE(order_time) = DATE('{{date}}')
  AND refund_amount > 0
ORDER BY refund_amount DESC
LIMIT 20

Note that {{shop_table}} and {{date}} are runtime placeholders, not SQL parameter bindings. This templating approach works well for batch report generation where each shop gets its own replacement.

Step 3: Report Generator

# report_generator.py
import duckdb
from pathlib import Path
from datetime import date, datetime, timedelta
import jinja2

class ReportGenerator:
    def __init__(self, engine, template_dir: str = "templates"):
        self.engine = engine
        self.env = jinja2.Environment(
            loader=jinja2.FileSystemLoader(template_dir),
            autoescape=True
        )
    
    def generate_report(self, shop_id: str, report_date: date) -> str:
        table_name = f"{shop_id}_orders"
        date_str = report_date.strftime("%Y-%m-%d")
        
        with self.engine.connection() as conn:
            summary = self._run_query(
                conn, "queries/daily_summary.sql",
                shop_table=table_name, date=date_str
            )
            categories = self._run_query(
                conn, "queries/category_breakdown.sql",
                shop_table=table_name, date=date_str
            )
            refunds = self._run_query(
                conn, "queries/refund_analysis.sql",
                shop_table=table_name, date=date_str
            )
            yesterday = self._run_query(
                conn, "queries/daily_summary.sql",
                shop_table=table_name,
                date=(report_date - timedelta(days=1)).strftime("%Y-%m-%d")
            )
        
        template = self.env.get_template("report.html")
        return template.render(
            shop_id=shop_id,
            report_date=report_date,
            summary=summar.fetchone() if summary else None,
            categories=categories.fetchall() if categories else [],
            refunds=refunds.fetchall() if refunds else [],
            yesterday=yesterday.fetchone() if yesterday else None,
            now=datetime.now().strftime("%Y-%m-%d %H:%M")
        )
    
    def _run_query(self, conn, query_file: str, **params):
        sql_text = Path(__file__).parent / query_file
        sql_text = sql_text.read_text()
        for key, value in params.items():
            sql_text = sql_text.replace(f"{{{{{key}}}}}", str(value))
        return conn.execute(sql_text)

Step 4: Main Entry Point & Scheduling

# main.py
import argparse
from datetime import date
from pathlib import Path
from engine import DuckRevenueEngine
from report_generator import ReportGenerator

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--date", type=str, default=date.today().isoformat())
    parser.add_argument("--shops", nargs="+", required=True)
    parser.add_argument("--output", default="reports/")
    args = parser.parse_args()
    
    engine = DuckRevenueEngine("revenue.duckdb")
    generator = ReportGenerator(engine)
    
    for shop_id in args.shops:
        report_date = date.fromisoformat(args.date)
        html = generator.generate_report(shop_id, report_date)
        
        out_path = f"{args.output}/{shop_id}_{args.date}.html"
        Path(out_path).parent.mkdir(parents=True, exist_ok=True)
        Path(out_path).write_text(html)
        print(f"📊 {shop_id} report generated: {out_path}")

if __name__ == "__main__":
    main()

Schedule with cron:

# Generate all shop reports daily at 22:00
0 22 * * * cd /home/user/duck_revenue_saaS && python main.py --shops shop001 shop002 shop003

Performance Comparison: Traditional vs DuckDB

DimensionPandas + CSVDuckDB
Data scale limitMemory-bound, slows at ~5M rowsColumnar compression, handles 1B+ rows
Query speedRow-by-row iteration, slow aggregationVectorized execution, 10-50x faster
Deployment complexityFile I/O + memory managementSingle file, zero configuration
Multi-tenant isolationManual file splittingTable-level isolation, native support
Cross-shop analysisMerge multiple CSVs firstDirect JOIN across same-db tables
Memory footprintHigh (full load)Low (columnar + compressed)

Monetization Path

The core value of this system is reusability:

  1. Build once, sell many: One codebase adapts to all shops, only data imports differ per client
  2. Per-shop pricing: ¥200-500/month per shop — 10 clients = ¥2,000-5,000/month
  3. Value-added features: Trend analysis, anomaly detection, competitor comparison — each a separate upsell
  4. SaaS upgrade: Transform HTML reports into a web dashboard with FastAPI, pushing monthly revenue to ¥10,000+

Deployment Options

  • Lightweight: Single Docker + cron, suitable for ≤10 clients
  • Standard: FastAPI + DuckDB + Redis cache, supports web access
  • Advanced: DuckDB Cloud or cloud-native deployment, multi-region support
# Quick start
pip install duckdb jinja2
python main.py --shops shop001 shop002 --date 2026-08-18

Learn more DuckDB hands-on experience → 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.