Dashboards People Actually Use

Article · 10:00

Most dashboards are opened twice. The ones that survive answer a specific recurring question, show change rather than level, and make it obvious when a number is broken rather than merely low.

This lesson has 8 runnable examples. Press Try yourself! under any of them to open it in the editor.

A dashboard that nobody opens twice has failed, however accurate it is. This lesson is about the handful of design decisions that decide which kind it is.

Build it for one recurring question

PY Example 1 16 lines
import pandas as pd

print(pd.DataFrame([
    {'kind': 'recurring question',
     'example': '"are we on track for the quarter?" - asked every Monday',
     'belongs in': 'a dashboard'},
    {'kind': 'one-off question',
     'example': '"why did March dip?" - asked once',
     'belongs in': 'an analysis, shared as a document'},
    {'kind': 'exploration',
     'example': '"what drives retention?" - open-ended',
     'belongs in': 'a notebook'},
    {'kind': 'everything we have',
     'example': '"all the numbers, just in case"',
     'belongs in': 'nowhere'},
]).to_string(index=False))

Open this example in the editor and change it. Nothing you type is saved.

The fourth row is how most dashboards are built and why most are abandoned. A dashboard is for a question somebody asks repeatedly, and its design should make that question answerable in seconds.

Write the question at the top of the dashboard, literally. It focuses the thing, and it tells a new reader what they are looking at.

Show change, not level

PY Example 2 20 lines
import numpy as np
import pandas as pd

rng = np.random.default_rng(3)
weeks = pd.date_range('2026-01-05', periods=12, freq='W-MON')
revenue = pd.Series((np.linspace(82_000, 96_000, 12) + rng.normal(0, 2_500, 12)).round(),
                    index=weeks)

latest = revenue.iloc[-1]
previous = revenue.iloc[-2]
year_base = revenue.iloc[0]

print('a tile that says only the level:')
print(f'   Revenue   {latest:,.0f}')
print()
print('the same tile, with context:')
print(f'   Revenue   {latest:,.0f}')
print(f'             {latest / previous - 1:+.1%} vs last week')
print(f'             {latest / year_base - 1:+.1%} vs 12 weeks ago')
print(f'             4-week mean {revenue.tail(4).mean():,.0f}')

Open this example in the editor and change it. Nothing you type is saved.

A number on its own is unreadable. Is 96,000 good? Nobody knows without the previous value, the trend, or the target. Every tile needs at least one comparison, and the useful ones are: the previous period, the same period last year, the target, and the recent average.

The sparkline is the cheapest context there is:

PY Example 3 22 lines
import numpy as np
import pandas as pd

rng = np.random.default_rng(5)

def sparkline(values):
    blocks = ' .:-=+*#'
    low, high = min(values), max(values)
    span = high - low or 1
    return ''.join(blocks[int((v - low) / span * (len(blocks) - 1))] for v in values)

metrics = {
    'revenue': np.linspace(82, 96, 20) + rng.normal(0, 2, 20),
    'signups': np.linspace(400, 380, 20) + rng.normal(0, 12, 20),
    'churn': np.linspace(2.1, 2.0, 20) + rng.normal(0, 0.25, 20),
}
for name, values in metrics.items():
    change = values[-1] / values[0] - 1
    print(f'{name:<9} {values[-1]:>8.1f}  {change:>+7.1%}  |{sparkline(values)}|')
print()
print('Twenty data points in twenty characters. The direction is')
print('visible without anybody clicking into a chart.')

Open this example in the editor and change it. Nothing you type is saved.

Make broken look different from bad

PY Example 4 23 lines
import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
days = pd.date_range('2026-03-01', periods=14, freq='D')
orders = pd.Series(rng.normal(1000, 50, 14).round(), index=days)
orders.iloc[-1] = 0          # the pipeline did not run
orders.iloc[-4] = 620        # a genuine bad day

median = orders.rolling(7, min_periods=3).median().shift(1)

for day, value in orders.items():
    expected = median.get(day, np.nan)
    if value == 0:
        state = 'NO DATA - pipeline did not run'
    elif not np.isnan(expected) and value < expected * 0.7:
        state = f'low ({value / expected - 1:+.0%} vs typical)'
    else:
        state = ''
    print(f'{day.date()}  {value:>6,.0f}  {state}')
print()
print('A zero and a bad day look identical on a line chart. They need')
print('different treatment, because they need different actions.')

Open this example in the editor and change it. Nothing you type is saved.

Three states a tile must be able to show, and most dashboards show only one:

  • A value. Normal.
  • No data. The pipeline has not run, or has not run yet. This must look obviously different from zero.
  • Stale. The data is old. Put the "as of" timestamp on every tile, and make it turn a colour when it is older than expected.

A zero that means "the pipeline failed" rendered as a zero that means "nothing happened" is the single most expensive dashboard bug, because it generates a panic and then a loss of trust.

Keep the definitions attached

PY Example 5 15 lines
DEFINITIONS = {
    'Weekly active learners': {
        'definition': 'distinct learners with >= 1 lesson completed, trailing 7 days',
        'excludes': 'internal accounts, unverified accounts',
        'source': 'warehouse.fct_lesson_events',
        'refreshed': 'daily 04:00 UTC',
        'restated': 'up to 3 days, for late events',
        'owner': 'growth analytics',
        'version': 'v3 since 2026-02-01',
    },
}
for metric, fields in DEFINITIONS.items():
    print(metric)
    for key, value in fields.items():
        print(f'  {key:<12} {value}')

Open this example in the editor and change it. Nothing you type is saved.

Every tile needs a tooltip with that content. The alternative is a weekly conversation about what the number means, and two teams reporting different values for the same name.

The "restated" line in particular. If today's number will change tomorrow because events arrive late, say so on the tile, or somebody will screenshot it and quote it in a meeting.

Fewer numbers, chosen

PY Example 6 9 lines
import pandas as pd

print(pd.DataFrame([
    {'tiles': '5-8', 'result': 'everything visible at once; people return to it'},
    {'tiles': '15-25', 'result': 'people look at three and ignore the rest'},
    {'tiles': '40+', 'result': 'nobody can tell what is important; abandoned'},
]).to_string(index=False))
print()
print('The hard part is deleting, not adding.')

Open this example in the editor and change it. Nothing you type is saved.

A good dashboard has one headline number, three or four supporting ones, and a way to get to the detail. Everything else belongs on a second page or in a query.

The test for whether a tile earns its place: when this number moves, does somebody do something different? If not, it is decoration, and it is competing for attention with the numbers that do matter.

PY Example 7 14 lines
import pandas as pd

tiles = pd.DataFrame([
    {'tile': 'weekly revenue', 'action if it moves': 'adjust forecast, alert sales'},
    {'tile': 'week-one activation', 'action if it moves': 'investigate onboarding'},
    {'tile': 'p95 page load', 'action if it moves': 'page the on-call engineer'},
    {'tile': 'total page views', 'action if it moves': 'none - moves with marketing'},
    {'tile': 'database row count', 'action if it moves': 'none'},
    {'tile': 'cumulative signups', 'action if it moves': 'none - always goes up'},
])
tiles['keep'] = tiles['action if it moves'] != 'none'
print(tiles.to_string(index=False))
print()
print(f'{tiles["keep"].sum()} of {len(tiles)} tiles survive the test.')

Open this example in the editor and change it. Nothing you type is saved.

Cumulative charts deserve a special mention: a line that only goes up conveys no information about whether things are improving. The weekly rate does. Replace every cumulative total with its rate of change.

Let people leave with the data

Every dashboard generates the question "can I see the rows behind this?". Answer it in the dashboard:

  • A download link on every table.
  • The query, visible or linked, so an analyst can take it and modify it.
  • A drill-down from an aggregate to the records.

Without these, every interesting number becomes a request to the analytics team, which is a tax on both sides and the reason dashboards stop being used.

A worked example

PY Example 8 122 lines
import numpy as np
import pandas as pd

rng = np.random.default_rng(seed=233)

# The data behind a weekly dashboard for one question:
# "are we on track for the quarter, and is anything broken?"
# Ten complete weeks of a thirteen-week quarter.
WEEKS_DONE, QUARTER_WEEKS = 10, 13
weeks = pd.date_range('2026-01-05', periods=WEEKS_DONE, freq='W-MON')
revenue = pd.Series((np.linspace(78_000, 92_000, WEEKS_DONE)
                     + rng.normal(0, 3_000, WEEKS_DONE)).round(), index=weeks)
activation = pd.Series(np.linspace(0.38, 0.41, WEEKS_DONE)
                       + rng.normal(0, 0.012, WEEKS_DONE), index=weeks)
p95_latency = pd.Series((np.linspace(900, 790, WEEKS_DONE)
                         + rng.normal(0, 40, WEEKS_DONE)).round(), index=weeks)
signups = pd.Series((np.linspace(2100, 2050, WEEKS_DONE)
                     + rng.normal(0, 90, WEEKS_DONE)).round(), index=weeks)

# The latest week's pipeline has not finished.
signups.iloc[-1] = np.nan

QUARTER_TARGET = 1_150_000
AS_OF = '2026-03-16 04:00 UTC'

def sparkline(values):
    blocks = ' .:-=+*#'
    clean = [v for v in values if not np.isnan(v)]
    low, high = min(clean), max(clean)
    span = high - low or 1
    return ''.join(' ' if np.isnan(v) else
                   blocks[int((v - low) / span * (len(blocks) - 1))] for v in values)

def tile(name, series, fmt, definition, higher_is_better=True):
    latest = series.iloc[-1]
    if np.isnan(latest):
        print(f'{name:<22} NO DATA YET      |{sparkline(series.to_numpy())}|')
        print(f'{"":<22} last complete week: {fmt(series.dropna().iloc[-1])}')
        print(f'{"":<22} {definition}')
        print()
        return
    previous = series.dropna().iloc[-2]
    change = latest / previous - 1
    direction = '+' if change >= 0 else '-'
    if abs(change) < 0.005:
        verdict = 'flat'
    else:
        verdict = 'as hoped' if (change > 0) == higher_is_better else 'wrong direction'
    print(f'{name:<22} {fmt(latest):>12}   {direction}{abs(change):.1%} wk/wk  '
          f'({verdict})')
    print(f'{"":<22} |{sparkline(series.to_numpy())}|  '
          f'4wk mean {fmt(series.dropna().tail(4).mean())}')
    print(f'{"":<22} {definition}')
    print()


print('=' * 78)
print('ARE WE ON TRACK FOR Q1, AND IS ANYTHING BROKEN?'.center(78))
print(f'as of {AS_OF}'.center(78))
print('=' * 78)
print()

# ---- The headline: one number, against a target. -------------------
to_date = revenue.sum()
weeks_done = len(revenue)
projected = to_date / weeks_done * QUARTER_WEEKS
print(f'{"Q1 revenue to date":<22} {to_date:>12,.0f}')
print(f'{"":<22} {to_date / QUARTER_TARGET:>11.1%} of the '
      f'{QUARTER_TARGET:,.0f} target, {weeks_done} of {QUARTER_WEEKS} weeks done')
print(f'{"":<22} at this run rate the quarter closes at {projected:,.0f} '
      f'({projected / QUARTER_TARGET - 1:+.1%})')
print(f'{"":<22} |{sparkline(revenue.to_numpy())}|')
print()
print('-' * 78)
print()

# ---- Three or four supporting tiles, each with an action. ----------
tile('Weekly revenue', revenue, lambda v: f'{v:,.0f}',
     'complete orders, excl. refunds. v2 since 2026-01-01.')
tile('Week-one activation', activation, lambda v: f'{v:.1%}',
     'verified signups completing >= 1 lesson in 7 days.')
tile('p95 page load', p95_latency, lambda v: f'{v:,.0f}ms',
     'all pages, all regions. Pages on-call above 1200ms.',
     higher_is_better=False)
tile('Weekly signups', signups, lambda v: f'{v:,.0f}',
     'verified, external. Restated for up to 3 days.')

print('-' * 78)
print()

# ---- Data health, so broken is distinguishable from bad. -----------
print('DATA HEALTH')
health = []
for name, series in [('revenue', revenue), ('activation', activation),
                     ('latency', p95_latency), ('signups', signups)]:
    missing = int(series.isna().sum())
    median = series.dropna().median()
    latest = series.dropna().iloc[-1]
    state = 'MISSING LATEST' if np.isnan(series.iloc[-1]) else (
        'anomalous' if abs(latest / median - 1) > 0.5 else 'ok')
    health.append({'metric': name, 'weeks': len(series), 'missing': missing,
                   'state': state})
print(pd.DataFrame(health).to_string(index=False))
print()
print('Signups shows NO DATA YET rather than zero, which is the whole')
print('point: a zero would have read as a catastrophe.')
print()

# ---- The tiles that did not make it, and why. ----------------------
print('-' * 78)
print()
print('DELIBERATELY NOT ON THIS DASHBOARD')
for name, reason in [
    ('cumulative signups', 'only goes up; the weekly rate says more'),
    ('total page views', 'moves with marketing spend; nobody acts on it'),
    ('average page load', 'the p95 is the one that pages someone'),
    ('revenue by 14 regions', 'most are too small to read; drill down instead'),
]:
    print(f'  {name:<22} {reason}')
print()
print('Every one of those was requested. Each would have competed for')
print('attention with the four numbers somebody acts on.')

Open this example in the editor and change it. Nothing you type is saved.

Five decisions make that readable. The question is written at the top. There is one headline with a target and a run rate, then four supporting tiles. Every tile has a week-on-week change, a sparkline and a one-line definition. The missing week shows as NO DATA YET rather than zero. And the tiles that were left out are listed with the reason, which is the part that keeps a dashboard from growing to forty tiles over a year.

When it goes wrong

Symptom Cause
Nobody opens it twice It answers no recurring question
A number means nothing on its own No comparison; add period-on-period
A pipeline failure looked like a crash Zero rendered as a value; show NO DATA
Two teams quote different values The definition is not on the tile
A screenshot was quoted after restatement No "restated for N days" note
Forty tiles and nobody reads any Nothing was ever deleted
A cumulative chart that only rises Show the rate of change instead
Every number generates a request No download or drill-down

A check you can run

Open your team's dashboard and, for each tile, ask: when this number moves, what does somebody do?

Delete every tile with no answer. In my experience that is half of them, and the remaining half become readable for the first time - which is the whole point, because a dashboard competes for a few seconds of attention and every unnecessary number spends some of it.