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.

Why This Combination?
Traditional BI architecture needs at least three components:
| Component | Traditional Approach | DuckDB + SQLite |
|---|---|---|
| Data Storage | PostgreSQL | SQLite (file-level) |
| Analytics Engine | ClickHouse | DuckDB (ATTACH SQLite) |
| API Service | FastAPI + SQLAlchemy | FastAPI + duckdb |
| Deployment Cost | $50+/month | $0 |
| Maintenance Complexity | High | Minimal |
| Data Latency | Minutes | Real-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:
- SMB Reporting Service: Build a SQLite + DuckDB reporting system for traditional businesses, charging ¥5,000-20,000 per project
- SaaS Built-in Analytics: Add an analytics module to your SaaS; DuckDB requires no additional server, saving pure profit
- Data Product Templates: Package this code as templates for developers needing quick reporting setup
- 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.