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.
Tableau 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
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.
Get in touch