Featured image of post DuckDB Multi-tenant Financial Report SaaS: From Single Client to Scalable Revenue

DuckDB Multi-tenant Financial Report SaaS: From Single Client to Scalable Revenue

Upgrade your DuckDB financial reporting project from custom development to a multi-tenant SaaS platform. Complete architecture with FastAPI, per-tenant databases, and subscription pricing model.

DuckDB Multi-tenant Financial Report SaaS: From Single Client to Scalable Revenue

In our previous article, we built a DuckDB-powered financial reporting system for a single enterprise and charged 20,000 RMB. But if you only serve 10 clients, you hit a 240,000 RMB annual ceiling.

The real money-making logic is turning your project into a SaaS — where clients run it themselves and you collect subscription fees.

Tonight, we upgrade from “custom development” to “standardized product”: a multi-tenant financial report SaaS platform with unlimited clients, automatic monthly generation, and subscription pricing.


1. Why Upgrade from Custom to SaaS?

The Problem with Custom Development

DimensionCustom DevelopmentSaaS Model
Revenue per Project20-50k RMB (one-time)299-999 RMB/month (recurring)
Maintenance CostCustom for each clientBuild once, all clients reuse
ScalabilityClients = your timeUnlimited clients
Valuation Multiple1-2x annual revenue10-20x annual revenue

Why DuckDB is Perfect for SaaS Backend

  1. Single-host multi-database: Each tenant gets its own .duckdb file — fully isolated, zero interference
  2. Zero ops: No need for PostgreSQL, Redis, or other external services — one binary handles everything
  3. Minimal cost: 100 tenants = 100 files, predictable memory pressure
  4. Batch processing: Python + DuckDB can generate thousands of reports simultaneously

2. System Architecture

┌─────────────────────────────────────────────────────┐
│                 User Layer (Web/App)                 │
│         Login → Select Tenant → View Report         │
└─────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────┐
│                FastAPI Gateway Layer                 │
│  /api/{tenant_id}/upload   Data upload              │
│  /api/{tenant_id}/query    Query execution           │
│  /api/scheduler/generate  Batch generation           │
└─────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────┐
│            DuckDB Multi-tenant Storage               │
│  customers/                                          │
│  ├── customer_001.duckdb    (Isolated DB)            │
│  ├── customer_002.duckdb    (Isolated DB)            │
│  ├── ...                                              │
│  └── master.duckdb         (Tenant metadata)         │
└─────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────┐
│              Report Rendering Layer                  │
│  Jinja2 Templates → Markdown/HTML → Email Push      │
└─────────────────────────────────────────────────────┘

3. Core Code Implementation

Tenant Database Manager

import duckdb
from pathlib import Path
from typing import Optional

class TenantDatabase:
    """Multi-tenant DuckDB database manager"""
    
    def __init__(self, db_root: str = "customers"):
        self.db_root = Path(db_root)
        self.db_root.mkdir(exist_ok=True)
        self.master_conn = duckdb.connect("master.duckdb")
        self._init_master()
    
    def _init_master(self):
        """Initialize metadata table"""
        self.master_conn.execute("""
            CREATE TABLE IF NOT EXISTS tenants (
                id VARCHAR PRIMARY KEY,
                name VARCHAR,
                plan VARCHAR DEFAULT 'basic',
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                data_files VARCHAR[]
            )
        """)
    
    def create_tenant(self, tenant_id: str, name: str) -> str:
        """Create isolated database for tenant"""
        db_path = self.db_root / f"{tenant_id}.duckdb"
        
        # Create independent database
        con = duckdb.connect(str(db_path))
        con.execute("""
            CREATE TABLE income (
                date DATE,
                category VARCHAR,
                amount DECIMAL(10,2),
                description VARCHAR
            )
        """)
        con.execute("""
            CREATE TABLE expense (
                date DATE,
                category VARCHAR,
                amount DECIMAL(10,2),
                vendor VARCHAR
            )
        """)
        con.close()
        
        # Record metadata
        self.master_conn.execute(
            "INSERT INTO tenants (id, name) VALUES (?, ?)",
            (tenant_id, name)
        )
        
        return str(db_path)
    
    def get_conn(self, tenant_id: str) -> duckdb.DuckDBPyConnection:
        """Get tenant database connection"""
        db_path = self.db_root / f"{tenant_id}.duckdb"
        return duckdb.connect(str(db_path))

Batch Report Generator

from datetime import datetime
import json

class ReportGenerator:
    """Report generation engine"""
    
    def __init__(self, db_manager: TenantDatabase):
        self.db = db_manager
    
    def generate_monthly_report(self, tenant_id: str, year: int, month: int) -> dict:
        """Generate single tenant monthly report"""
        conn = self.db.get_conn(tenant_id)
        
        period = f"{year}-{month:02d}"
        
        # Income summary
        income = conn.execute(f"""
            SELECT 
                category,
                SUM(amount) as total,
                COUNT(*) as transactions
            FROM income
            WHERE strftime(date, '%Y-%m') = '{period}'
            GROUP BY category
            ORDER BY total DESC
        """).fetchall()
        
        # Expense summary
        expense = conn.execute(f"""
            SELECT 
                category,
                SUM(amount) as total,
                COUNT(*) as transactions
            FROM expense
            WHERE strftime(date, '%Y-%m') = '{period}'
            GROUP BY category
            ORDER BY total DESC
        """).fetchall()
        
        # Key metrics
        metrics = conn.execute(f"""
            SELECT 
                SUM(CASE WHEN type='income' THEN amount ELSE 0 END) as total_income,
                SUM(CASE WHEN type='expense' THEN amount ELSE 0 END) as total_expense,
                SUM(CASE WHEN type='income' THEN amount ELSE 0 END) - 
                SUM(CASE WHEN type='expense' THEN amount ELSE 0 END) as net_profit,
                ROUND(
                    (SUM(CASE WHEN type='income' THEN amount ELSE 0 END) - 
                     SUM(CASE WHEN type='expense' THEN amount ELSE 0 END)) * 100.0 /
                    NULLIF(SUM(CASE WHEN type='income' THEN amount ELSE 0 END), 0),
                    2
                ) as profit_margin
            FROM (
                SELECT date, 'income' as type, amount FROM income
                UNION ALL
                SELECT date, 'expense' as type, amount FROM expense
            ) t
            WHERE strftime(t.date, '%Y-%m') = '{period}'
        """).fetchone()
        
        conn.close()
        
        return {
            'tenant_id': tenant_id,
            'period': period,
            'generated_at': datetime.now().isoformat(),
            'income': [{'category': r[0], 'total': r[1], 'count': r[2]} for r in income],
            'expense': [{'category': r[0], 'total': r[1], 'count': r[2]} for r in expense],
            'metrics': {
                'total_income': metrics[0] or 0,
                'total_expense': metrics[1] or 0,
                'net_profit': metrics[2] or 0,
                'profit_margin': metrics[3] or 0
            }
        }
    
    def generate_all_tenants(self, year: int, month: int):
        """Batch generate reports for all tenants"""
        tenants = self.db.master_conn.execute(
            "SELECT id FROM tenants"
        ).fetchall()
        
        results = []
        for (tenant_id,) in tenants:
            try:
                report = self.generate_monthly_report(tenant_id, year, month)
                results.append(report)
            except Exception as e:
                print(f"Tenant {tenant_id} failed: {e}")
                results.append({'tenant_id': tenant_id, 'error': str(e)})
        
        # Save summary
        output_path = Path(f"reports/{year}_{month:02d}_batch.json")
        output_path.parent.mkdir(exist_ok=True)
        output_path.write_text(json.dumps(results, indent=2, ensure_ascii=False))
        
        return results

FastAPI Service

from fastapi import FastAPI, UploadFile, File, HTTPException
import asyncio

app = FastAPI(title="DuckDB Financial SaaS")
db_manager = TenantDatabase()
report_gen = ReportGenerator(db_manager)

@app.post("/api/{tenant_id}/upload")
async def upload_data(tenant_id: str, file: UploadFile = File(...)):
    """Upload CSV/Excel data"""
    conn = db_manager.get_conn(tenant_id)
    
    filename = file.filename.lower()
    if filename.endswith('.csv'):
        content = await file.read()
        df = conn.sql(f"SELECT * FROM read_csv_auto(STRING_SPLIT({content.decode()!r}, E'\\n'))")
    elif filename.endswith(('.xlsx', '.xls')):
        df = conn.sql(f"SELECT * FROM read_xlsx('/tmp/{tenant_id}.xlsx')")
    
    table_name = df.columns[0]
    conn.execute(f"INSERT INTO {table_name} SELECT * FROM df")
    conn.close()
    
    return {"status": "success", "rows": len(df)}

@app.post("/api/{tenant_id}/report")
async def generate_report(tenant_id: str, year: int, month: int):
    """Generate single tenant report"""
    try:
        report = report_gen.generate_monthly_report(tenant_id, year, month)
        return report
    except Exception as e:
        raise HTTPException(status_code=404, detail=str(e))

@app.post("/api/scheduler/generate")
async def batch_generate(year: int, month: int):
    """Batch generate all tenant reports"""
    results = await asyncio.to_thread(
        report_gen.generate_all_tenants, year, month
    )
    return {"total": len(results), "success": len([r for r in results if 'error' not in r])}

4. Scheduling and Deployment

Cron Job for Monthly Generation

# schedule.py
import schedule
import time

def daily_report_job():
    """Batch generate on 1st of each month at 2 AM"""
    now = datetime.now()
    if now.day == 1:
        results = report_gen.generate_all_tenants(now.year, now.month)
        send_notification(results)

schedule.every().day.at("02:00").do(daily_report_job)

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

Docker Deployment

FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .
RUN mkdir -p customers reports

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

5. Monetization Strategy

Pricing Model

PlanPriceFeatures
Basic299 RMB/monthSingle tenant, monthly report, CSV import
Professional699 RMB/monthMulti-tenant, multi-source, API access
Enterprise1999 RMB/monthPrivate deployment, custom reports, support

Customer Acquisition Channels

  1. Content Marketing: Publish DuckDB financial automation articles on Zhihu, Juejin
  2. Free Trial: 7-day free trial to reduce decision barrier
  3. Channel Partnerships: Partner with accounting firms, revenue sharing
  4. Community Building: Create financial data analysis discussion groups

Revenue Projection

Assuming you acquire 100 paying users through content marketing:

  • Basic (70%): 70 × 299 = 20,930 RMB/month
  • Professional (25%): 25 × 699 = 17,475 RMB/month
  • Enterprise (5%): 5 × 1999 = 9,995 RMB/month

Monthly Total: 48,400 RMB Annual Total: 580,000 RMB

Compared to custom development, this revenue is sustainable and scalable.


6. Advanced: Data Lake Architecture

When you exceed 100 tenants, individual files may not scale. Upgrade to:

-- Query Parquet data lake with DuckDB
SELECT 
    tenant_id,
    date_trunc('month', date) as month,
    SUM(amount) as total
FROM read_parquet('s3://bucket/reports/*.parquet')
GROUP BY 1, 2
ORDER BY 1, 2;

This maintains multi-tenant isolation while supporting cross-tenant analytics.


Summary

The core change from custom development to SaaS:

  1. Architecture Upgrade: Single database → Multi-tenant isolated databases
  2. Process Automation: Manual delivery → Scheduled batch generation
  3. Product Standardization: Customized → Subscription-based

DuckDB’s multi-tenant capability (isolated files + zero ops) is the foundation of this approach.

Next Steps:

  1. Deploy a Minimum Viable Product (MVP)
  2. Find 3-5 companies for free trial
  3. Collect feedback and iterate
  4. Start charging

💡 More DuckDB practical tips → 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.