How to Build Dashboards Without BI Tools: Python + HTML

·6 min read·Data & Dashboards

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.

How to Build Dashboards Without BI Tools: Python + HTMLAI Generated Image

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

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

how to build dashboards without bi toolsbuild dashboard pythonpython dashboard without tableaudata dashboard no licencepython html dashboardbuild dashboard from scratchlightweight data dashboardpython plotly dashboardcustom data dashboardself-hosted dashboard python