How to Build Dashboards Without BI Tools: Python + HTML
Build interactive data dashboards using Python and HTML templates instead of expensive BI tools — from data processing to chart rendering to automated deployment, with no licence fees.
AI Generated ImageTableau costs £70 per user per month. Power BI Pro is £8.40. Looker requires a sales call that somehow takes three meetings.
A Python script, a few HTML templates, and a static file server cost nothing. They also give you complete control over how your data is processed, styled, and deployed.
This is not a toy approach. Production dashboards serving real teams run on exactly this pattern: process data with Python, render charts with a JavaScript library, output static HTML that any web server can host.
# Who This Is For
- Small teams who need dashboards but cannot justify BI tool subscriptions
- Data engineers building internal dashboards for operations teams
- Vibe coders wanting to visualise data without learning a proprietary tool
- Founders who need investor-ready dashboards without enterprise software
Basic Python and HTML knowledge is all you need. No JavaScript framework experience required.
# The Architecture
flowchart LR
subgraph Data["Data Layer"]
DB[(Database)]
API[APIs]
CSV[CSV Files]
end
subgraph Process["Python Processing"]
ETL["Extract &\nTransform"]
AGG["Aggregate\nMetrics"]
CHART["Generate\nChart Data"]
end
subgraph Render["Rendering"]
TPL["Jinja2\nTemplates"]
JS["Chart.js /\nPlotly.js"]
end
subgraph Deploy["Output"]
HTML["Static HTML"]
HOST["Any Web Server"]
end
DB --> ETL
API --> ETL
CSV --> ETL
ETL --> AGG --> CHART
CHART --> TPL
TPL --> HTML
JS --> HTML
HTML --> HOST# What You Will Need
pip install pandas jinja2 plotly
No BI tool licence. No server-side rendering engine. No JavaScript build tools.
# Step 1: Build the Data Pipeline
Pull data from whatever sources you have and aggregate into dashboard-ready metrics.
import pandas as pd
from datetime import datetime, timedelta
class DashboardData:
"""Process raw data into dashboard-ready metrics."""
def __init__(self, data_source: str):
"""Load data from a CSV, database, or API response."""
if data_source.endswith(".csv"):
self.df = pd.read_csv(data_source, parse_dates=["date"])
else:
from sqlalchemy import create_engine
engine = create_engine(data_source)
self.df = pd.read_sql("SELECT * FROM metrics", engine)
print(f"Loaded {len(self.df)} rows")
def daily_summary(self, days: int = 30) -> pd.DataFrame:
"""Aggregate daily metrics for the dashboard."""
cutoff = datetime.now() - timedelta(days=days)
recent = self.df[self.df["date"] >= cutoff].copy()
summary = recent.groupby(recent["date"].dt.date).agg(
revenue=("amount", "sum"),
orders=("order_id", "nunique"),
customers=("customer_id", "nunique"),
).reset_index()
summary["aov"] = (summary["revenue"] / summary["orders"]).round(2)
print(f"Daily summary: {len(summary)} days")
return summary
def category_breakdown(self) -> pd.DataFrame:
"""Break down metrics by category."""
breakdown = self.df.groupby("category").agg(
revenue=("amount", "sum"),
orders=("order_id", "nunique"),
avg_price=("amount", "mean"),
).reset_index()
breakdown["revenue_pct"] = (
(breakdown["revenue"] / breakdown["revenue"].sum()) * 100
).round(1)
return breakdown.sort_values("revenue", ascending=False)
# Step 2: Create Chart Configurations
Convert pandas DataFrames into chart-ready JSON that Plotly.js can render directly.
import json
def revenue_line_chart(daily: pd.DataFrame) -> str:
"""Generate Plotly.js config for a revenue line chart."""
chart = {
"data": [{
"x": [str(d) for d in daily["date"]],
"y": daily["revenue"].tolist(),
"type": "scatter",
"mode": "lines+markers",
"name": "Revenue",
"line": {"color": "#6366f1", "width": 2},
"marker": {"size": 4},
}],
"layout": {
"title": "Daily Revenue",
"xaxis": {"title": "Date"},
"yaxis": {"title": "Revenue (£)", "tickprefix": "£"},
"template": "plotly_white",
"height": 400,
},
}
return json.dumps(chart, default=str)
def category_pie_chart(breakdown: pd.DataFrame) -> str:
"""Generate Plotly.js config for a category breakdown."""
chart = {
"data": [{
"labels": breakdown["category"].tolist(),
"values": breakdown["revenue"].tolist(),
"type": "pie",
"textinfo": "label+percent",
"marker": {
"colors": ["#6366f1", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"],
},
}],
"layout": {
"title": "Revenue by Category",
"template": "plotly_white",
"height": 400,
},
}
return json.dumps(chart, default=str)
def orders_bar_chart(daily: pd.DataFrame) -> str:
"""Generate Plotly.js config for an orders bar chart."""
chart = {
"data": [{
"x": [str(d) for d in daily["date"]],
"y": daily["orders"].tolist(),
"type": "bar",
"name": "Orders",
"marker": {"color": "#22c55e"},
}],
"layout": {
"title": "Daily Orders",
"xaxis": {"title": "Date"},
"yaxis": {"title": "Orders"},
"template": "plotly_white",
"height": 400,
},
}
return json.dumps(chart, default=str)
# Step 3: Build the HTML Template
A single Jinja2 template renders the complete dashboard. Plotly.js loads from CDN.
DASHBOARD_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ title }}</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, sans-serif; background: #f8fafc; color: #1e293b; }
.header { background: #1e293b; color: white; padding: 2rem; }
.header h1 { font-size: 1.5rem; }
.header .updated { font-size: 0.875rem; color: #94a3b8; margin-top: 0.25rem; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; padding: 2rem; }
.kpi { background: white; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.kpi .label { font-size: 0.875rem; color: #64748b; }
.kpi .value { font-size: 2rem; font-weight: 700; margin-top: 0.25rem; }
.kpi .change { font-size: 0.875rem; margin-top: 0.25rem; }
.kpi .change.up { color: #22c55e; }
.kpi .change.down { color: #ef4444; }
.charts { display: grid; grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); gap: 1rem; padding: 0 2rem 2rem; }
.chart { background: white; border-radius: 8px; padding: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
</style>
</head>
<body>
<div class="header">
<h1>{{ title }}</h1>
<div class="updated">Last updated: {{ updated_at }}</div>
</div>
<div class="kpis">
{% for kpi in kpis %}
<div class="kpi">
<div class="label">{{ kpi.label }}</div>
<div class="value">{{ kpi.value }}</div>
<div class="change {{ 'up' if kpi.change > 0 else 'down' }}">
{{ '+' if kpi.change > 0 }}{{ kpi.change }}%% vs last period
</div>
</div>
{% endfor %}
</div>
<div class="charts">
{% for chart in charts %}
<div class="chart">
<div id="chart-{{ loop.index }}"></div>
</div>
{% endfor %}
</div>
<script>
{% for chart in charts %}
(function() {
var config = {{ chart | safe }};
Plotly.newPlot('chart-{{ loop.index }}', config.data, config.layout, {responsive: true});
})();
{% endfor %}
</script>
</body>
</html>"""
# Step 4: Calculate KPI Cards
KPI cards show headline numbers with period-over-period comparisons.
def calculate_kpis(daily: pd.DataFrame) -> list[dict]:
"""Calculate KPI values with week-over-week changes."""
if len(daily) < 14:
return []
this_week = daily.tail(7)
last_week = daily.iloc[-14:-7]
def pct_change(current, previous):
if previous == 0:
return 0
return round(((current - previous) / previous) * 100, 1)
tw_revenue = this_week["revenue"].sum()
lw_revenue = last_week["revenue"].sum()
tw_orders = this_week["orders"].sum()
lw_orders = last_week["orders"].sum()
tw_aov = tw_revenue / tw_orders if tw_orders > 0 else 0
lw_aov = lw_revenue / lw_orders if lw_orders > 0 else 0
tw_customers = this_week["customers"].sum()
lw_customers = last_week["customers"].sum()
return [
{"label": "Revenue (7d)", "value": f"£{tw_revenue:,.0f}", "change": pct_change(tw_revenue, lw_revenue)},
{"label": "Orders (7d)", "value": f"{tw_orders:,}", "change": pct_change(tw_orders, lw_orders)},
{"label": "Avg Order Value", "value": f"£{tw_aov:.2f}", "change": pct_change(tw_aov, lw_aov)},
{"label": "Customers (7d)", "value": f"{tw_customers:,}", "change": pct_change(tw_customers, lw_customers)},
]
# Step 5: Render the Dashboard
Combine data, charts, and templates into a single HTML file.
from jinja2 import Template
def render_dashboard(data_source: str, output_path: str = "dashboard.html"):
"""Generate the complete dashboard HTML."""
# Process data
data = DashboardData(data_source)
daily = data.daily_summary(days=30)
categories = data.category_breakdown()
# Build components
kpis = calculate_kpis(daily)
charts = [
revenue_line_chart(daily),
orders_bar_chart(daily),
category_pie_chart(categories),
]
# Render template
template = Template(DASHBOARD_TEMPLATE)
html = template.render(
title="Store Dashboard",
updated_at=datetime.now().strftime("%Y-%m-%d %H:%M"),
kpis=kpis,
charts=charts,
)
with open(output_path, "w") as f:
f.write(html)
print(f"Dashboard rendered to {output_path}")
return output_path
# Step 6: Automate Daily Updates
Schedule the dashboard to regenerate every morning before the team arrives.
import subprocess
def deploy_dashboard(data_source: str, deploy_path: str = "/var/www/dashboard/"):
"""Generate and deploy the dashboard."""
output = "dashboard.html"
render_dashboard(data_source, output)
# Copy to web server directory
import shutil
shutil.copy(output, deploy_path)
print(f"Dashboard deployed to {deploy_path}")
if __name__ == "__main__":
deploy_dashboard("sales_data.csv", "/var/www/dashboard/index.html")
Schedule with cron:
# Update dashboard at 6am daily
0 6 * * * cd /srv/dashboard && python render.py
Or with Prefect:
from prefect import flow
@flow
def daily_dashboard():
"""Regenerate the dashboard daily."""
deploy_dashboard("sqlite:///warehouse.db")
daily_dashboard.serve(
name="morning-dashboard",
schedules=[{"cron": "0 6 * * *", "timezone": "Europe/London"}],
)
# What This Replaces
| BI Tool Approach | Python + HTML Equivalent |
|---|---|
| £70/user/month Tableau licence | Free (Python + Plotly.js) |
| Proprietary query language | Standard SQL + pandas |
| Vendor-hosted data | Your data stays on your servers |
| Limited customisation | Full CSS/HTML control |
| Requires training | Uses skills your team already has |
| Vendor lock-in on dashboards | Portable HTML files |
# Next Steps
For the data pipeline that feeds your dashboard, see Build a Data Dashboard Without Excel. For automated data collection from ecommerce APIs, see Shopify Reporting API: Pull Sales, Inventory, and Customer Data.
For scheduling dashboard updates with retry logic, see Schedule and Orchestrate Workflows with Prefect. For adding data quality checks before rendering, see Build a Data Quality Framework in Python.
Data analytics services include custom dashboard development, data pipeline design, and reporting automation.
Get in touch to discuss building a custom dashboard for your team.
Enjoyed this article?
Get notified when I publish new articles on automation, ecommerce, and data engineering.