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

- Author: Fawad Hussain Syed, software engineer, Geneva (https://fawadhs.dev/about)
- Published: 2026-05-15
- Category: Data & Dashboards
- Canonical: https://fawadhs.dev/blog/build-dashboards-without-bi-tools-python

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

```mermaid
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

```bash
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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:

```bash
# Update dashboard at 6am daily
0 6 * * * cd /srv/dashboard && python render.py
```

Or with Prefect:

```python
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](/blog/build-data-dashboard-without-excel). For automated data collection from ecommerce APIs, see [Shopify Reporting API: Pull Sales, Inventory, and Customer Data](/blog/shopify-reporting-api-pull-sales-inventory-data).

For scheduling dashboard updates with retry logic, see [Schedule and Orchestrate Workflows with Prefect](/blog/schedule-orchestrate-workflows-prefect). For adding data quality checks before rendering, see [Build a Data Quality Framework in Python](/blog/build-data-quality-framework-python).

[Data analytics services](/services/data-analytics) include custom dashboard development, data pipeline design, and reporting automation.

[Get in touch](/contact) to discuss building a custom dashboard for your team.
