Featured image of post DuckDB + SQLite: Build a Lightweight BI Dashboard at Zero Cost

DuckDB + SQLite: Build a Lightweight BI Dashboard at Zero Cost

Use DuckDB ATTACH SQLite as an analysis engine to build zero-server-cost BI dashboards for small businesses. Includes full code, FastAPI integration, and monetization strategies.

DuckDB + SQLite: Build a Lightweight BI Dashboard at Zero Cost

When building data analytics products, most people’s first instinct is PostgreSQL, MySQL, or ClickHouse. But for indie developers and small teams, these solutions are heavy: they require deployment, maintenance, and cloud database costs.

Today I’ll show you a zero-server-cost BI approach: SQLite handles transactional writes, DuckDB performs analytical queries, connected seamlessly via ATTACH, with Python tying everything together.

DuckDB + SQLite Architecture

Why This Combination?

Traditional BI architecture needs at least three components:

ComponentTraditional ApproachDuckDB + SQLite
Data StoragePostgreSQLSQLite (file-level)
Analytics EngineClickHouseDuckDB (ATTACH SQLite)
API ServiceFastAPI + SQLAlchemyFastAPI + duckdb
Deployment Cost$50+/month$0
Maintenance ComplexityHighMinimal
Data LatencyMinutesReal-time

The core idea is simple: transactional databases handle writes, analytical engines handle reads. SQLite manages daily CRUD operations, while DuckDB reads directly from the SQLite file through ATTACH, allowing SQL queries to span both layers transparently.

One, Environment Setup

pip install duckdb fastapi uvicorn pandas pyarrow

No database services needed. DuckDB is an embedded engine, and SQLite is built into Python.

Two, Create Sample Business Data

First, build a SQLite database simulating real business scenarios:

import sqlite3
import random
from datetime import datetime, timedelta

conn = sqlite3.connect("business.db")
cur = conn.cursor()

cur.execute("""
CREATE TABLE IF NOT EXISTS orders (
    id INTEGER PRIMARY KEY,
    order_date TEXT,
    customer_id TEXT,
    product TEXT,
    quantity INTEGER,
    amount REAL,
    region TEXT
)
""")

cur.execute("""
CREATE TABLE IF NOT EXISTS customers (
    id TEXT PRIMARY KEY,
    name TEXT,
    signup_date TEXT,
    tier TEXT
)
""")

# Generate 500 orders
random.seed(42)
products = ["Pro", "Enterprise", "Basic", "API Calls", "Storage"]
regions = ["East", "South", "North", "West", "Central"]

for i in range(1, 501):
    date = datetime(2025, 1, 1) + timedelta(days=random.randint(0, 365))
    cur.execute(
        "INSERT INTO orders VALUES (?, ?, ?, ?, ?, ?, ?)",
        (i, date.strftime("%Y-%m-%d"), f"C{random.randint(1,100):03d}",
         random.choice(products), random.randint(1, 20),
         round(random.uniform(50, 5000), 2), random.choice(regions))
    )

# Generate 100 customers
customers = [
    (f"C{i:03d}", f"Customer {i}", f"2024-{random.randint(1,12):02d}-01",
     random.choice(["Free", "Standard", "Premium"]))
    for i in range(1, 101)
]
cur.executemany("INSERT INTO customers VALUES (?, ?, ?, ?)", customers)

conn.commit()
conn.close()
print(f"✅ Created {len(customers)} customers, 500 orders")

Three, Connect SQLite and Analyze with DuckDB

This is the critical step — DuckDB can ATTACH a SQLite file directly:

import duckdb

con = duckdb.connect()

# Key: ATTACH the SQLite file
con.execute("ATTACH 'business.db' AS biz (TYPE SQLITE)")

# Query 1: Monthly Revenue Trend
monthly_revenue = con.execute("""
    SELECT
        DATE_TRUNC('month', order_date::DATE) AS month,
        SUM(amount) AS revenue,
        COUNT(*) AS orders,
        ROUND(AVG(amount), 2) AS avg_order_value
    FROM biz.orders
    GROUP BY month
    ORDER BY month
""").fetchdf()

print("📈 Monthly Revenue Trend:")
for _, row in monthly_revenue.iterrows():
    print(f"  {row.month}: ${row.revenue:,.0f} | Orders {int(row.orders)} | AOV ${row.avg_order_value:.0f}")

# Query 2: Customer Tier Cross-Analysis
customer_analysis = con.execute("""
    WITH customer_orders AS (
        SELECT
            c.id, c.tier,
            SUM(o.amount) AS total_spend,
            COUNT(DISTINCT o.product) AS products_bought,
            COUNT(*) AS order_count
        FROM biz.customers c
        LEFT JOIN biz.orders o ON c.id = o.customer_id
        GROUP BY c.id, c.tier
    )
    SELECT
        tier,
        COUNT(*) AS customer_count,
        ROUND(AVG(total_spend), 0) AS avg_spend,
        ROUND(SUM(total_spend), 0) AS total_revenue,
        ROUND(AVG(order_count), 1) AS avg_orders
    FROM customer_orders
    GROUP BY tier
    ORDER BY total_revenue DESC
""").fetchdf()

print("\n👥 Customer Tier Analysis:")
for _, row in customer_analysis.iterrows():
    print(f"  {row.tier}: {int(row.customer_count)} users | Avg Spend ${row.avg_spend:,.0f} | Total Revenue ${row.total_revenue:,.0f}")

# Query 3: Identify VIP Customers
vip_list = con.execute("""
    SELECT c.id, c.name, c.tier, SUM(o.amount) AS total_spend, COUNT(*) AS order_count
    FROM biz.customers c
    JOIN biz.orders o ON c.id = o.customer_id
    GROUP BY c.id, c.name, c.tier
    HAVING SUM(o.amount) > 5000 OR COUNT(*) >= 10
    ORDER BY total_spend DESC
    LIMIT 20
""").fetchall()

print("\n⭐ VIP Customers TOP 20:")
for v in vip_list:
    print(f"  {v[1]} ({v[0]}): ${v[3]:,.0f} | {int(v[4])} orders")

Example output:

📈 Monthly Revenue Trend:
  2025-01-01: $48,230 | Orders 42 | AOV $1,148
  2025-02-01: $52,100 | Orders 47 | AOV $1,109
  ...

👥 Customer Tier Analysis:
  Premium: 35 users | Avg Spend $4,280 | Total Revenue $149,800
  Standard: 42 users | Avg Spend $2,150 | Total Revenue $90,300
  Free: 23 users | Avg Spend $890 | Total Revenue $20,470

⭐ VIP Customers TOP 20:
  Customer 007 (C007): $12,450 | 18 orders
  Customer 042 (C042): $10,230 | 15 orders
  ...

Four, Export Analysis Reports

Export results as JSON for frontend consumption or email delivery:

import json

report = {
    "monthly_revenue": monthly_revenue.to_dict(orient="records"),
    "tier_analysis": customer_analysis.to_dict(orient="records"),
    "vip_customers": [list(v) for v in vip_list]
}

with open("analysis_report.json", "w", encoding="utf-8") as f:
    json.dump(report, f, ensure_ascii=False, indent=2)

print("✅ Report exported to analysis_report.json")

Five, Wrap as FastAPI Service

Package the logic into API endpoints callable by any frontend:

from fastapi import FastAPI
import duckdb

app = FastAPI()

@app.get("/api/revenue/monthly")
def get_monthly_revenue():
    con = duckdb.connect()
    con.execute("ATTACH 'business.db' AS biz (TYPE SQLITE)")
    result = con.execute("""
        SELECT DATE_TRUNC('month', order_date::DATE) AS month,
               SUM(amount) AS revenue, COUNT(*) AS orders
        FROM biz.orders GROUP BY month ORDER BY month
    """).fetchdf().to_dict(orient="records")
    con.close()
    return result

@app.get("/api/customers/vip")
def get_vip_customers():
    con = duckdb.connect()
    con.execute("ATTACH 'business.db' AS biz (TYPE SQLITE)")
    result = con.execute("""
        SELECT c.id, c.name, SUM(o.amount) AS total_spend, COUNT(*) AS order_count
        FROM biz.customers c JOIN biz.orders o ON c.id = o.customer_id
        GROUP BY c.id, c.name
        HAVING SUM(o.amount) > 5000
        ORDER BY total_spend DESC LIMIT 50
    """).fetchall()
    con.close()
    return [{"id": r[0], "name": r[1], "total_spend": r[2], "order_count": r[3]} for r in result]

Start command:

uvicorn main:app --host 0.0.0.0 --port 8000

Connect your frontend to these endpoints using ECharts or Chart.js, and you have a complete BI dashboard.

Six, Advanced Optimization: Parquet Acceleration

As data grows, periodically export SQLite data to Parquet format. DuckDB reads Parquet 10x faster than SQLite:

import duckdb

con = duckdb.connect()
con.execute("ATTACH 'business.db' AS biz (TYPE SQLITE)")

# Export to Parquet
con.execute("COPY (SELECT * FROM biz.orders) TO 'orders.parquet' (FORMAT PARQUET)")

# Then query directly from Parquet
parquet_con = duckdb.connect()
result = parquet_con.execute("""
    SELECT DATE_TRUNC('month', order_date::DATE) AS month,
           SUM(amount) AS revenue
    FROM read_parquet('orders.parquet')
    GROUP BY month ORDER BY month
""").fetchdf()

In production, use cron to schedule exports, or trigger them after each business data update.

Seven, Monetization Paths

The commercial value of this approach lies in extremely low delivery costs:

  1. SMB Reporting Service: Build a SQLite + DuckDB reporting system for traditional businesses, charging ¥5,000-20,000 per project
  2. SaaS Built-in Analytics: Add an analytics module to your SaaS; DuckDB requires no additional server, saving pure profit
  3. Data Product Templates: Package this code as templates for developers needing quick reporting setup
  4. Consulting + Deployment: Help clients migrate from Excel to this architecture, billed per project

The key advantage: the entire analytics layer is just a Python library — no DBA required, no maintenance overhead, near-zero deployment cost.

Summary

The DuckDB + SQLite combination is ideal for:

  • Indie developer data product MVPs
  • Small team internal reporting systems
  • Client projects requiring rapid delivery
  • Local analytics on mobile/edge devices

If you’re looking for an analytics solution that doesn’t depend on cloud services, doesn’t require a DBA, and works out of the box, this combination is worth trying.

💡 Want to learn more about building complete data products with DuckDB? duckdblab.org has a full tutorial series covering SQLite integration, Parquet performance optimization, and production deployment — perfect for developers who want to ship real products.

📺 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.