Featured image of post Replace Tableau with DuckDB: Build a Zero-Cost Enterprise BI System (Full Python Code)

Replace Tableau with DuckDB: Build a Zero-Cost Enterprise BI System (Full Python Code)

Small and medium businesses spend $10,000+/year on Tableau licenses when 80% of their BI needs are just turning SQL results into reports. This article shows you how to build a zero-cost BI alternative using DuckDB + Python + Plotly — complete with KPI dashboards, Pareto analysis, customer RFM segmentation, interactive HTML dashboards, and multi-sheet Excel exports. All code is copy-paste runnable

The Pain: Does Your Business Really Need Tableau?

The other 80% of their BI workflow looks like this: operations opens a report → glances at trends → downloads Excel → sends to the boss. That’s it.

In one sentence: Your BI budget is burning, and all you’re getting is a few bar charts.

Tableau is a great product, but its value proposition is being challenged:

AspectTableauPower BIDuckDB + Python (This Solution)
License Cost$75/person/month$10/person/month$0
DeploymentNeeds serverNeeds WindowsOne Python script
Data VolumeVaries by serverVaries by config100GB+ (columnar storage)
Learning Curve2-4 weeks1-2 weeks1 day (if you know SQL)
CustomizationLowMediumFully controllable
Scheduled RefreshNeeds Tableau ServerNeeds Power BI ServiceOne cron line
Output FormatsPlatform-onlyPlatform-onlyHTML/Excel/PDF/Email

Of course, Tableau’s drag-and-drop interactivity and geospatial visualizations have unique strengths. But for 80% of SME BI needs, the DuckDB + Python + Plotly lightweight solution is more than adequate — and costs nothing in licensing.


Why DuckDB Excels in BI Scenarios

Why choose DuckDB as your BI engine instead of Pandas or SQLite?

  1. Columnar storage: Analytical queries are natively accelerated; only the needed columns are read
  2. Zero configuration: No need to install a database server — pip install duckdb and you’re done
  3. Memory efficient: Supports Spill to Disk; an 8GB laptop can handle 100GB datasets
  4. Full SQL support: Window functions, CTEs, complex aggregations — far more intuitive than Pandas’ method chaining
  5. Multi-format reading: Directly reads CSV/Parquet/JSON/Excel, even from HTTP URLs

The key differentiator for BI reporting: DuckDB’s window functions make year-over-year comparisons, rankings, and RFM analysis trivial, and the Python integration is seamless — con.execute(sql).fetchdf() gives you a Pandas DataFrame ready for Plotly visualization.


Complete Code: DuckDB BI Report Generator

Here’s a complete Python script you can copy to bi_report.py and run immediately.

Prerequisites

pip install duckdb pandas openpyxl plotly

Tested with: DuckDB 1.5.2, Python 3.11, Plotly 6.7.0

Core Script

The script does three things:

  1. Auto-generates 50,000 synthetic sales records (12 months, 8 categories, 40 SKUs, 20 provinces)
  2. Runs 7 DuckDB-powered BI analyses: KPI dashboard, monthly trends, category Pareto, regional distribution, channel analysis, customer RFM segmentation, and Top 20 products
  3. Outputs two deliverables: an interactive HTML dashboard and a multi-sheet Excel report

If you have real data, just replace df_sales with SELECT * FROM 'your_data.csv'.

#!/usr/bin/env python3
"""
DuckDB BI Report Generator — Tableau Alternative
Generates a complete enterprise BI analysis report
(HTML dashboard + Excel report) using DuckDB + Plotly.
"""

import duckdb
import pandas as pd
import numpy as np
from datetime import datetime, timedelta, date
import random
from pathlib import Path

# ============================================================
# Step 1: Connect to DuckDB
# ============================================================
con = duckdb.connect()

# ============================================================
# Step 2: Generate 50,000 synthetic sales records
# Replace with: CREATE TABLE sales AS SELECT * FROM 'sales.csv'
# ============================================================
print("🔄 Generating synthetic sales data...")

random.seed(42)
np.random.seed(42)

categories = {
 "Electronics": ["Laptop", "Mechanical Keyboard", "Bluetooth Earbuds",
 "USB-C Hub", "Monitor Stand", "4K Webcam",
 "Wireless Mouse", "External SSD"],
 "Clothing": ["Down Jacket", "Running Shoes", "Casual Pants",
 "Hoodie", "Knit Sweater", "Baseball Cap",
 "Canvas Bag", "Sun Protection Jacket"],
 "Food & Beverage": ["Premium Coffee Beans", "Nut Gift Box",
 "Organic Tea", "Protein Bar", "Sparkling Water",
 "Chocolate Gift Set", "Freeze-Dried Fruit",
 "Instant Bird's Nest"],
 "Home Goods": ["Latex Pillow", "Smart Lamp", "Insulated Mug",
 "Aroma Diffuser", "Storage Box", "Door Mat",
 "Bath Towel Set", "Desktop Fan"],
 "Beauty": ["Serum", "Face Cream", "Sunscreen", "Facial Cleanser",
 "Sheet Mask (10pk)", "Hand Cream", "Lip Balm",
 "Shampoo"],
 "Baby & Toys": ["Baby Stroller", "Early Learning Device",
 "Building Blocks Set", "Kids Water Bottle",
 "Educational Puzzle", "Comfort Doll",
 "Kids Electric Toothbrush", "Story Machine"],
 "Sports & Outdoors": ["Yoga Mat", "Sports Bottle",
 "Running Waist Pack", "Resistance Band",
 "Trekking Pole", "Picnic Mat",
 "Jump Rope", "Knee Brace"],
 "Books & Stationery": ["Journal", "Pen Set", "Calendar",
 "Bookmark Gift Set", "Postcard Set",
 "Sticker Pack", "Ink", "Pencil Case"]
}

provinces = ["Guangdong", "Zhejiang", "Jiangsu", "Beijing", "Shanghai",
 "Shandong", "Sichuan", "Henan", "Hubei", "Hunan",
 "Fujian", "Anhui", "Hebei", "Chongqing", "Shaanxi",
 "Liaoning", "Yunnan", "Guangxi", "Jiangxi", "Tianjin"]
province_weights = [15, 12, 12, 10, 9, 7, 6, 5, 5, 4, 4, 3, 3, 3, 2, 2, 2, 2, 2, 2]

customers = [f"C{str(i).zfill(5)}" for i in range(1, 501)]
channels = ["Taobao", "JD.com", "Pinduoduo", "Douyin Shop",
 "WeChat Mini Program", "Offline Store"]

num_orders = 50000
start_date = date(2025, 5, 1)
end_date = date(2026, 4, 30)

orders = []
for i in range(num_orders):
 order_date = start_date + timedelta(
 days=random.randint(0, (end_date - start_date).days))
 cat = random.choice(list(categories.keys()))
 product = random.choice(categories[cat])
 qty = random.choice([1, 1, 1, 1, 2, 2, 3])

 price_map = {
 "Electronics": (50, 5000, 800), "Clothing": (30, 2000, 350),
 "Food & Beverage": (20, 800, 150), "Home Goods": (10, 600, 120),
 "Beauty": (30, 1500, 280), "Baby & Toys": (20, 3000, 400),
 "Sports & Outdoors": (15, 800, 160),
 "Books & Stationery": (5, 300, 60)
 }
 price_low, price_high, price_mode = price_map[cat]
 unit_price = max(price_low, min(price_high,
 int(np.random.exponential(price_mode))))
 amount = round(unit_price * qty, 2)
 cost = round(amount * random.uniform(0.4, 0.7), 2)

 orders.append({
 "order_id": f"ORD{202500000 + i}",
 "order_date": order_date.isoformat(),
 "year": order_date.year,
 "month": order_date.month,
 "category": cat,
 "product": product,
 "quantity": qty,
 "unit_price": unit_price,
 "amount": amount,
 "cost": cost,
 "profit": round(amount - cost, 2),
 "province": random.choices(
 provinces, weights=province_weights, k=1)[0],
 "channel": random.choice(channels),
 "customer_id": random.choice(customers),
 })

df_sales = pd.DataFrame(orders)
print(f"✓ Generated {len(df_sales):,} order records")

con.execute("DROP TABLE IF EXISTS sales")
con.execute("CREATE TABLE sales AS SELECT * FROM df_sales")

# ============================================================
# Step 3: DuckDB Multi-Dimensional BI Analysis
# ============================================================
print("\n📊 Running BI analysis queries...")

# KPI Dashboard
kpi = con.execute("""
 SELECT
 COUNT(*) AS total_orders,
 ROUND(SUM(amount), 0) AS total_revenue,
 ROUND(AVG(amount), 2) AS avg_order_value,
 ROUND(SUM(profit), 0) AS total_profit,
 ROUND(SUM(profit) / NULLIF(SUM(amount), 0) * 100, 1)
 AS profit_margin_pct,
 COUNT(DISTINCT customer_id) AS unique_customers
 FROM sales
""").fetchdf()

# Monthly Trends
trend = con.execute("""
 SELECT year, month,
 (year::VARCHAR || '-' ||
 LPAD(month::VARCHAR, 2, '0')) AS ym,
 COUNT(*) AS order_count,
 ROUND(SUM(amount), 0) AS revenue,
 ROUND(SUM(profit), 0) AS profit,
 ROUND(AVG(amount), 2) AS avg_order_value
 FROM sales
 GROUP BY year, month
 ORDER BY year, month
""").fetchdf()

# Category Pareto Analysis
category_rank = con.execute("""
 SELECT category, COUNT(*) AS order_count,
 ROUND(SUM(amount), 0) AS revenue,
 ROUND(SUM(profit), 0) AS profit,
 ROUND(100.0 * SUM(amount) /
 SUM(SUM(amount)) OVER (), 1) AS revenue_pct,
 ROUND(SUM(SUM(amount)) OVER
 (ORDER BY SUM(amount) DESC)
 / SUM(SUM(amount)) OVER () * 100, 1)
 AS cumulative_pct
 FROM sales
 GROUP BY category
 ORDER BY revenue DESC
""").fetchdf()

# Regional Analysis
region = con.execute("""
 SELECT province, COUNT(*) AS order_count,
 ROUND(SUM(amount), 0) AS revenue,
 ROW_NUMBER() OVER
 (ORDER BY SUM(amount) DESC) AS rank
 FROM sales
 GROUP BY province ORDER BY revenue DESC
""").fetchdf()

# Channel Analysis
channel_df = con.execute("""
 SELECT channel, COUNT(*) AS order_count,
 ROUND(SUM(amount), 0) AS revenue,
 ROUND(SUM(profit), 0) AS profit,
 ROUND(100.0 * SUM(amount) /
 SUM(SUM(amount)) OVER (), 1) AS revenue_share
 FROM sales
 GROUP BY channel ORDER BY revenue DESC
""").fetchdf()

# Customer RFM Segmentation
customer_segments = con.execute("""
 WITH rfm AS (
 SELECT customer_id, COUNT(*) AS frequency,
 ROUND(SUM(amount), 0) AS monetary,
 DATEDIFF('day',
 MAX(order_date)::DATE,
 '2026-04-30'::DATE) AS recency,
 ROUND(SUM(profit), 0) AS total_profit
 FROM sales GROUP BY customer_id
 ),
 scores AS (
 SELECT *,
 NTILE(5) OVER
 (ORDER BY monetary DESC) AS m_score,
 NTILE(5) OVER
 (ORDER BY frequency DESC) AS f_score,
 NTILE(5) OVER
 (ORDER BY recency ASC) AS r_score
 FROM rfm
 )
 SELECT
 CASE WHEN r_score >= 4 AND m_score >= 4
 THEN '💎 High-Value Active'
 WHEN r_score >= 3 AND m_score >= 3
 THEN '⭐ Mid-Value Active'
 WHEN r_score <= 2 AND m_score >= 4
 THEN '💰 High-Value Sleeping'
 WHEN r_score <= 2 AND m_score <= 2
 THEN '📉 Churned'
 ELSE '👤 Regular'
 END AS segment,
 COUNT(*) AS customer_count,
 ROUND(SUM(monetary), 0) AS total_revenue,
 ROUND(AVG(monetary), 0) AS avg_revenue
 FROM scores
 GROUP BY segment ORDER BY total_revenue DESC
""").fetchdf()

# Top 20 Products
top_products = con.execute("""
 SELECT product, category, COUNT(*) AS order_count,
 ROUND(SUM(amount), 0) AS revenue,
 ROUND(SUM(profit), 0) AS profit
 FROM sales
 GROUP BY product, category
 ORDER BY revenue DESC LIMIT 20
""").fetchdf()

print("✓ All BI queries complete")

# ============================================================
# Step 4: Generate Interactive HTML Dashboard
# ============================================================
print("\n📄 Generating HTML dashboard...")

import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio
from plotly.subplots import make_subplots

# Chart 1: Monthly Revenue Trend
fig1 = go.Figure()
fig1.add_trace(go.Bar(x=trend['ym'], y=trend['revenue'],
 name='Monthly Revenue', marker_color='#17BECF', opacity=0.7))
fig1.add_trace(go.Scatter(x=trend['ym'],
 y=trend['revenue'].rolling(3, min_periods=1).mean(),
 name='3-Month Moving Avg',
 line=dict(color='#FF6B35', width=3), mode='lines+markers'))
fig1.update_layout(title='📈 Monthly Revenue Trend',
 template='plotly_white', height=450, hovermode='x unified')

# Chart 2: Category Pareto (bar + cumulative line)
fig2 = make_subplots(specs=[[{"secondary_y": True}]])
fig2.add_trace(go.Bar(x=category_rank['category'],
 y=category_rank['revenue'],
 name='Revenue', marker_color='#2E86AB',
 text=category_rank['revenue'].apply(
 lambda x: f'${x/1000:.0f}K')),
 secondary_y=False)
fig2.add_trace(go.Scatter(x=category_rank['category'],
 y=category_rank['cumulative_pct'],
 name='Cumulative %',
 line=dict(color='#FF6B35', width=3, dash='dot'),
 mode='lines+markers+text',
 text=category_rank['cumulative_pct'].apply(
 lambda x: f'{x}%')),
 secondary_y=True)
fig2.add_shape(type='line', x0=-0.5, y0=80, x1=7.5, y1=80,
 line=dict(color='red', width=2, dash='dash'))
fig2.update_layout(title='📊 Category Pareto Analysis',
 template='plotly_white', height=450,
 xaxis={'categoryorder': 'total descending'})
fig2.update_yaxes(title_text='Revenue ($)', secondary_y=False)
fig2.update_yaxes(title_text='Cumulative %', secondary_y=True,
 range=[0, 105])

# Chart 3: Channel Pie
fig3 = px.pie(channel_df, values='revenue', names='channel',
 title='🔵 Revenue by Channel', hole=0.4,
 color_discrete_sequence=px.colors.qualitative.Set2)
fig3.update_traces(textposition='inside', textinfo='percent+label')

# Chart 4: Customer Segmentation
colors_map = {'💎 High-Value Active': '#2ECC71',
 '⭐ Mid-Value Active': '#3498DB',
 '💰 High-Value Sleeping': '#F39C12',
 '📉 Churned': '#E74C3C',
 '👤 Regular': '#95A5A6'}
fig4 = go.Figure()
fig4.add_trace(go.Bar(
 x=customer_segments['segment'],
 y=customer_segments['customer_count'],
 marker_color=[colors_map.get(s, '#95A5A6')
 for s in customer_segments['segment']],
 text=customer_segments['customer_count'],
 textposition='outside'))
fig4.update_layout(title='👥 Customer Segmentation',
 template='plotly_white', height=400)

# Chart 5: Top 10 Regions
fig5 = px.bar(region.head(10).sort_values('revenue'),
 x='revenue', y='province',
 orientation='h', title='🗺️ Top 10 Provinces by Revenue',
 text=region.head(10)['revenue'].apply(
 lambda x: f'${x/1000:.0f}K'),
 color='revenue', color_continuous_scale='Viridis',
 height=500)
fig5.update_layout(yaxis={'categoryorder': 'total ascending'},
 template='plotly_white')

# Assemble HTML
chart1_html = pio.to_html(fig1, include_plotlyjs=True,
 full_html=False)
chart2_html = pio.to_html(fig2, include_plotlyjs=False,
 full_html=False)
chart3_html = pio.to_html(fig3, include_plotlyjs=False,
 full_html=False)
chart4_html = pio.to_html(fig4, include_plotlyjs=False,
 full_html=False)
chart5_html = pio.to_html(fig5, include_plotlyjs=False,
 full_html=False)

kpi_revenue = f"${int(kpi['total_revenue'].iloc[0]):,}"
kpi_orders = f"{int(kpi['total_orders'].iloc[0]):,}"
kpi_avg = f"${kpi['avg_order_value'].iloc[0]:.0f}"
kpi_profit = f"{kpi['profit_margin_pct'].iloc[0]}%"

html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <title>DuckDB BI Dashboard — Sales Analytics</title>
 <script src="https://cdn.plot.ly/plotly-3.0.1.min.js"></script>
 <style>
 * {{ margin:0; padding:0; box-sizing:border-box; }}
 body {{
 font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',
 Roboto,sans-serif;
 background:#f5f7fa; color:#2c3e50; padding:20px;
 }}
 .header {{
 background:linear-gradient(135deg,#667eea,#764ba2);
 color:white; padding:30px; border-radius:12px;
 margin-bottom:24px;
 }}
 .header h1 {{ font-size:28px; margin-bottom:8px; }}
 .kpi-grid {{
 display:grid; grid-template-columns:repeat(4,1fr);
 gap:16px; margin-bottom:24px;
 }}
 .kpi-card {{
 background:white; padding:20px; border-radius:10px;
 box-shadow:0 2px 8px rgba(0,0,0,0.08);
 text-align:center;
 }}
 .kpi-card .value {{ font-size:28px; font-weight:700; }}
 .kpi-card .label {{ font-size:13px; color:#7f8c8d;
 margin-top:4px; }}
 .kpi-card:nth-child(1) .value {{ color:#2ECC71; }}
 .kpi-card:nth-child(2) .value {{ color:#3498DB; }}
 .kpi-card:nth-child(3) .value {{ color:#F39C12; }}
 .kpi-card:nth-child(4) .value {{ color:#E74C3C; }}
 .chart-row {{
 display:grid; grid-template-columns:1fr 1fr;
 gap:16px; margin-bottom:16px;
 }}
 .chart-card {{
 background:white; padding:16px; border-radius:10px;
 box-shadow:0 2px 8px rgba(0,0,0,0.08);
 }}
 .chart-full {{
 background:white; padding:16px; border-radius:10px;
 box-shadow:0 2px 8px rgba(0,0,0,0.08);
 margin-bottom:16px;
 }}
 .footer {{
 text-align:center; padding:20px; color:#95a5a6;
 font-size:12px;
 }}
 @media (max-width:768px) {{
 .kpi-grid {{ grid-template-columns:repeat(2,1fr); }}
 .chart-row {{ grid-template-columns:1fr; }}
 }}
 </style>
</head>
<body>
 <div class="header">
 <h1>🦆 DuckDB BI Dashboard</h1>
 <p>Enterprise Sales Analytics |
 {datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
 </div>
 <div class="kpi-grid">
 <div class="kpi-card">
 <div class="value">{kpi_revenue}</div>
 <div class="label">📊 Total Revenue</div>
 </div>
 <div class="kpi-card">
 <div class="value">{kpi_orders}</div>
 <div class="label">📦 Total Orders</div>
 </div>
 <div class="kpi-card">
 <div class="value">{kpi_avg}</div>
 <div class="label">💰 Avg Order Value</div>
 </div>
 <div class="kpi-card">
 <div class="value">{kpi_profit}</div>
 <div class="label">📈 Profit Margin</div>
 </div>
 </div>
 <div class="chart-full">{chart1_html}</div>
 <div class="chart-row">
 <div class="chart-card">{chart2_html}</div>
 <div class="chart-card">{chart3_html}</div>
 </div>
 <div class="chart-row">
 <div class="chart-card">{chart4_html}</div>
 <div class="chart-card">{chart5_html}</div>
 </div>
 <div class="footer">
 <p>🦆 Replace Tableau with DuckDB —
 Zero-Cost Enterprise BI Solution</p>
 </div>
</body>
</html>"""

output_dir = Path(".")
html_path = output_dir / "duckdb_bi_dashboard.html"
html_path.write_text(html_content, encoding='utf-8')
print(f"✓ HTML dashboard saved: {html_path}")

# ============================================================
# Step 5: Export Excel Report
# ============================================================
excel_path = output_dir / "duckdb_bi_report.xlsx"
with pd.ExcelWriter(excel_path, engine='openpyxl') as writer:
 kpi.to_excel(writer, sheet_name='KPI Overview', index=False)
 trend.to_excel(writer, sheet_name='Monthly Trends', index=False)
 category_rank.to_excel(writer, sheet_name='Category Rank',
 index=False)
 region.to_excel(writer, sheet_name='Regional Analysis',
 index=False)
 channel_df.to_excel(writer, sheet_name='Channel Analysis',
 index=False)
 customer_segments.to_excel(writer, sheet_name='Customer RFM',
 index=False)
 top_products.to_excel(writer, sheet_name='Top 20 Products',
 index=False)

print(f"✓ Excel report saved: {excel_path}")

con.close()
print("\n✅ Complete! Deliverables:")
print(f" 1. HTML Dashboard → {html_path}")
print(f" 2. Excel Report → {excel_path}")

Ad-hoc Queries: Explore Data Like Tableau

Beyond the preset reports, DuckDB’s ad-hoc query capability is a core selling point for this BI alternative. Here are three common questions clients ask:

1. Which category sells best each month?

WITH monthly_category AS (
 SELECT year, month, category, SUM(amount) AS revenue,
 ROW_NUMBER() OVER (
 PARTITION BY year, month
 ORDER BY SUM(amount) DESC) AS rn
 FROM sales GROUP BY year, month, category
)
SELECT (year::VARCHAR || '-' || LPAD(month::VARCHAR, 2, '0'))
 AS month,
 category AS top_category, ROUND(revenue, 0) AS revenue
FROM monthly_category WHERE rn = 1
ORDER BY year, month;

2. What’s the customer repeat purchase breakdown?

SELECT
 CASE WHEN order_count >= 10 THEN '🔟 VIP (10+ orders)'
 WHEN order_count >= 5 THEN '⭐ Loyal (5-9 orders)'
 WHEN order_count >= 2 THEN '👍 Returning (2-4 orders)'
 ELSE '🆕 Low-frequency (1 order)'
 END AS customer_type,
 COUNT(*) AS customer_count,
 ROUND(AVG(total_spent), 0) AS avg_spent,
 ROUND(SUM(total_spent), 0) AS total_revenue
FROM (
 SELECT customer_id, COUNT(*) AS order_count,
 ROUND(SUM(amount), 0) AS total_spent
 FROM sales GROUP BY customer_id
)
GROUP BY customer_type ORDER BY MIN(order_count);

3. Which day of the week has the highest sales?

SELECT CASE CAST(strftime(order_date::DATE, '%w') AS INTEGER)
 WHEN 0 THEN 'Sunday' WHEN 1 THEN 'Monday'
 WHEN 2 THEN 'Tuesday' WHEN 3 THEN 'Wednesday'
 WHEN 4 THEN 'Thursday' WHEN 5 THEN 'Friday'
 WHEN 6 THEN 'Saturday'
END AS weekday,
COUNT(*) AS order_count, ROUND(SUM(amount), 0) AS revenue
FROM sales GROUP BY weekday ORDER BY revenue DESC;

Architectural Advantages of the DuckDB BI Solution

How It Differs from Traditional BI Tools

Traditional BI tools (Tableau, Power BI, Metabase) work like this:

Data Source → ETL → Data Warehouse → BI Server → Frontend Rendering

Each layer requires configuration, tuning, and payment.

The DuckDB BI approach is:

Data Files (CSV/Excel/Parquet) → DuckDB SQL → Python (Plotly) → HTML/Excel

Just one Python script — no middlemen taking a cut.

Cost Comparison

For a 10-person SME team, annual BI costs:

Cost ItemTableauDuckDB BI Solution
Licenses$9,000 (10×)$0
Server$1,200 ( VM)$0
Implementation$3,000 (third-party)****
Maintenance$1,200 (part-time)$0
Annual Total$14,400****
Savings—96%+

Scheduled Auto-Refresh with Cron

One of the biggest advantages of this solution is the dead-simple scheduled refresh:

# Refresh reports daily at 9 AM
0 9 * * * cd /path/to/project && python bi_report.py

You can even email the HTML report automatically:

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

msg = MIMEMultipart()
msg['Subject'] = '📊 Daily Sales Report - DuckDB BI'
msg.attach(MIMEText(html_content, 'html'))

with smtplib.SMTP('smtp.example.com', 587) as server:
 server.starttls()
 server.login('user@example.com', 'password')
 server.send_message(msg)

Key Takeaways

The DuckDB + Python + Plotly combination is a severely underrated enterprise BI solution. It doesn’t compete head-to-head with Tableau on every feature. Instead, it dominates in the “medium-complexity BI reporting” sweet spot — zero cost, fully customizable, and completely controllable.

For a small to medium business, spending thousands on BI licensing makes far less sense than paying a fraction to a developer who knows DuckDB to build a customized reporting system.

If you’re a data analyst or freelance developer, this solution is your secret weapon for consulting gigs — your client’s pain point is painful enough (thousands in annual Tableau fees), your delivery cost is low enough (one Python script), and your margin is 90%+.

All code verified with DuckDB v1.5.2, Python 3.11, Plotly 6.7.0 Content sourced from DuckDB Golden Practice Channel — Day 13


本文信息

项目内容
DuckDB 版本v1.5.x(部分功能基于 v2.0 Preview)
最后验证2026-09-12
测试环境Linux / x86_64 / 16GB RAM
官方文档DuckDB Documentation
GitHubpengzz9527/duckdb-blog

如发现错误,欢迎通过 GitHub Issue 或邮件 contact@duckdblab.org 反馈。

📺 Watch video tutorials → Olap Studio YouTube

Subscribe for more DuckDB & AI automation tutorials