How-to
Formatting And Charting Excel Reports With PythonDeep dive

Create a Bullet Chart in Excel with Python

Build a bullet chart — qualitative bands, an actual-value bar and a target marker — as a native Excel chart from Python: normalise KPIs with different units, draw the bands as stacked columns, draw the actual as a thick error bar and the target as a dash marker on the same axis, tidy the legend and axis, and check the rendered result. Includes why the common secondary-axis recipe is fragile.

A dashboard with four gauges takes up a lot of space to say little. The bullet chart, designed as a compact replacement, shows the same information in a strip: grey bands for "poor", "satisfactory" and "good", a dark bar for the actual value, and a short line for the target. Several KPIs sit side by side, and a reader sees at a glance which ones fall short. Excel has no bullet chart type, but it can be assembled from parts a Python script controls: a stacked column chart for the bands, plus a second series for the actual and one for the target. The construction details decide whether the chart renders correctly everywhere, so this page builds it step by step with XlsxWriter and checks the rendered output. It belongs to Creating Charts in Excel with openpyxl.

Prerequisites

Bash
pip install xlsxwriter pandas pillow

LibreOffice (soffice) and pdftoppm render the chart in Step 6 for a visual check; they are optional. The example tracks four KPIs with different units — revenue in thousands, margin in percent, a Net Promoter Score and on-time delivery — each with an actual value, a target and the upper limits of its "poor" and "satisfactory" bands.

Step 1 — Put the KPIs on one scale

Revenue of 270 and a margin of 22 cannot share an axis. Express everything as a percentage of the KPI's "good" ceiling, so every column runs from 0 to 100:

Python
import pandas as pd

kpis = pd.DataFrame({
    "KPI": ["Revenue", "Margin", "NPS", "On-time"],
    "Actual": [270, 22, 48, 91],
    "Target": [250, 25, 50, 95],
    "PoorUpTo": [200, 15, 30, 80],
    "OkUpTo": [260, 20, 45, 90],
    "Max": [300, 30, 60, 100],
})
scale = 100 / kpis["Max"]
chart_data = pd.DataFrame({
    "KPI": kpis["KPI"],
    "Actual %": (kpis["Actual"] * scale).round(1),
    "Target %": (kpis["Target"] * scale).round(1),
    "Poor": (kpis["PoorUpTo"] * scale).round(1),
    "Satisfactory": ((kpis["OkUpTo"] - kpis["PoorUpTo"]) * scale).round(1),
    "Good": ((kpis["Max"] - kpis["OkUpTo"]) * scale).round(1),
})
print(chart_data.to_string(index=False))

The band columns are the heights of each band, not their upper limits, because a stacked chart adds them up: Poor 66.7 + Satisfactory 20 + Good 13.3 = 100 for revenue. Keeping the original values in their own columns lets a table next to the chart show real numbers while the chart uses the normalised ones.

KPIs in different units on one percentage scale On the left the raw KPI values with revenue 270 against a ceiling of 300 and margin 22 against 30. On the right the same values as a percentage of each ceiling — 90 and 73.3 — with band heights that add up to 100. A B C D 1 KPI Actual Target Max 2 Revenue 270 250 300 3 Margin 22 25 30 4 NPS 48 50 60 A B C D E F 1 KPI Actual % Target % Poor OK Good 2 Revenue 90 83.3 66.7 20 13.3 3 Margin 73.3 83.3 50 16.7 33.3 4 NPS 80 83.3 50 25 25 raw values % of the good ceiling × 100 / Max

Step 2 — Draw the bands as stacked columns

Python
import xlsxwriter

book = xlsxwriter.Workbook("kpi_bullets.xlsx")
sheet = book.add_worksheet("KPIs")
sheet.write_row(0, 0, list(chart_data.columns))
for r, row in enumerate(chart_data.itertuples(index=False), start=1):
    sheet.write_row(r, 0, row)
n = len(chart_data)
cats = ["KPIs", 1, 0, n, 0]

bands = book.add_chart({"type": "column", "subtype": "stacked"})
for col, colour in ((3, "#BFC6D4"), (4, "#DDE2EA"), (5, "#EEF1F5")):
    bands.add_series({"name": ["KPIs", 0, col], "categories": cats,
                      "values": ["KPIs", 1, col, n, col], "fill": {"color": colour}, "gap": 40})

Three stacked series make the bands, darkest for "poor" at the bottom and lightest for "good" at the top — the conventional order, which makes the bands readable in greyscale print. A small gap ("gap": 40) makes each KPI's band wide, so the actual bar has room inside it.

Step 3 — Draw the actual value as a thick error bar

The obvious recipe — a second, narrower column series on a secondary axis — is fragile: in testing with LibreOffice, the secondary column series was not drawn at all and the secondary axis ignored its fixed 0–100 scale. A sturdier construction keeps everything on one axis. Add the actual values as a line series with no line and no marker, and give it a vertical error bar that runs 100% down from each point to zero, drawn as a thick dark line:

Python
lines = book.add_chart({"type": "line"})
lines.add_series({
    "name": "Actual", "categories": cats, "values": ["KPIs", 1, 1, n, 1],
    "line": {"none": True}, "marker": {"type": "none"},
    "y_error_bars": {"type": "percentage", "value": 100, "direction": "minus",
                     "end_style": 0, "line": {"color": "#1E293B", "width": 14}},
})

An error bar of 100% in the minus direction always reaches exactly from the value to zero, so it behaves like a bar of the right height, centred in the category — independent of any axis settings. end_style: 0 removes the end caps, and the 14-point line width makes it a slim column inside the band. Error bars from Python are covered in Add Error Bars to an Excel Chart with openpyxl.

The three layers of the bullet chart The bottom layer is a stacked column series for the poor, satisfactory and good bands. On top, a line series with no line carries a 100 percent minus error bar drawn as a thick dark line for the actual value. The top layer is a line series with long dash markers for the target. Target · line series, long-dash markers no line, red marker at the target height Actual · line series with error bar 100% minus error bar, 14 pt dark line Bands · stacked columns Poor, Satisfactory, Good — light to dark greys

Step 4 — Add the target marker

The target is a second series in the same line chart: no line, and a long horizontal dash as its marker:

Python
lines.add_series({
    "name": "Target", "categories": cats, "values": ["KPIs", 1, 2, n, 2],
    "line": {"none": True},
    "marker": {"type": "long_dash", "size": 22,
               "fill": {"color": "#DC2626"}, "border": {"color": "#DC2626"}},
})
bands.combine(lines)

combine overlays the line chart's series on the column chart, sharing its axes. A red dash is the usual target symbol; size 22 makes it a little wider than the actual bar, so it stays visible when the actual is close to the target.

Step 5 — Axis, title and legend

Python
bands.set_y_axis({"min": 0, "max": 100, "major_unit": 20, "num_format": '0"%"',
                  "major_gridlines": {"visible": True, "line": {"color": "#E5E7EB"}}})
bands.set_title({"name": "KPIs as % of the good threshold"})
bands.set_legend({"position": "bottom", "delete_series": [3]})
bands.set_size({"width": 640, "height": 360})
sheet.insert_chart("H2", bands)
sheet.set_column("A:A", 12)
book.close()
print("chart written with", n, "KPIs")

Fixing the axis at 0–100 makes every band reach the top of the plot. The number format 0"%" shows the values as percentages without multiplying them by 100. delete_series: [3] removes the fourth legend entry — the invisible "Actual" line series, whose legend symbol would be empty — leaving Poor, Satisfactory, Good and Target. Placing a small table of the raw values beside the chart answers the inevitable "what is 73%?" question; see Add a KPI Summary Block to an Excel Dashboard.

Step 6 — Check the rendered chart

Charts built from several series are easy to break with one wrong index. Render the sheet and confirm that the actual bars and target markers really appear:

Python
import subprocess
from PIL import Image

subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "kpi_bullets.xlsx"], check=True, capture_output=True)
subprocess.run(["pdftoppm", "-png", "-r", "60", "-f", "1", "-l", "1", "kpi_bullets.pdf", "kpi_bullets"], check=True)
img = Image.open("kpi_bullets-1.png").convert("RGB")
counts = {colour: n for n, colour in img.getcolors(maxcolors=1_000_000)}
for label, rgb in {"actual bars": (0x1E, 0x29, 0x3B), "target markers": (0xDC, 0x26, 0x26),
                   "poor band": (0xBF, 0xC6, 0xD4)}.items():
    print(f"{label:15} {'present' if counts.get(rgb, 0) > 50 else 'MISSING'} ({counts.get(rgb, 0)} px)")

Counting pixels of each series colour proves that every layer was drawn — the check that would have caught the missing secondary-axis series. In the render, Revenue's bar rises past its target into the good band, Margin falls short in the satisfactory band, NPS sits just below target, and On-time is just below its 95% target in the good band.

The four KPIs against their targets Actual values as a percentage of each KPI's good ceiling, with targets. Revenue 90 against a target of 83.3, Margin 73.3 against 83.3, NPS 80 against 83.3 and On-time 91 against 95. Actual % Target % 0 25 50 75 100 Revenue Margin NPS On-time 90 73.3 80 91 83.3 83.3 83.3 95

Common pitfalls

  • Mixing units on one axis. Normalise each KPI to its own ceiling first.
  • Band upper limits instead of heights. Stacked columns add values; store each band's height.
  • Relying on a secondary axis. It renders inconsistently across applications; the error-bar construction stays on one axis.
  • An empty legend entry. Delete the invisible actual series from the legend.
  • Colour-only meaning. Keep the bands in light-to-dark greys so the chart survives greyscale printing.

Conclusion

A bullet chart in Excel is three layers on one axis: stacked columns for the poor, satisfactory and good bands, an invisible line series whose 100% minus error bar draws the actual value as a thick bar, and a second line series with long-dash markers for the target. Normalise KPIs to a common percentage scale first, fix the axis at 0–100, remove the invisible series from the legend, and render the chart in a test to make sure every layer appears.

Frequently asked questions

Can Excel make a bullet chart? Not as a built-in chart type, but you can build one by combining a stacked column chart for the bands with series for the actual value and the target. Python libraries such as XlsxWriter can create it in one script.

How do I show the actual value as a bar inside the bands? Add the actual values as a line series with no line and no marker, and give it a y error bar of 100 percent in the minus direction with a thick line. The error bar runs from the value down to zero like a narrow column.

How do I put KPIs with different units in one bullet chart? Express each KPI as a percentage of its own maximum or "good" threshold, so every KPI runs from 0 to 100, and show the raw values in a table beside the chart.

Why does my secondary-axis bullet chart look wrong? Secondary axes on combined column charts are rendered inconsistently: in testing, LibreOffice ignored the fixed axis scale and did not draw the secondary column series. Keeping all series on the primary axis with the error-bar technique avoids the problem.

More on charts & images

More on charts & images →