Create a Histogram in Excel with Python
Averages hide most of what matters about a set of values: whether they bunch together tightly or spread widely, whether there are two groups instead of one, whether a handful of huge orders drag the mean up. A histogram shows all of that at a glance — how many values fall in each range. Excel has a built-in histogram chart, but it belongs to a newer chart format that neither openpyxl nor xlsxwriter can write. The reliable approach is to count the values into bins in Python and chart the counts as touching columns. This page, part of Creating Charts in Excel with openpyxl, builds a histogram of order values that way.
Prerequisites
pip install numpy pandas openpyxl xlsxwriter
orders.xlsx holds 480 order values. Most are between about 40 and 250; a handful are far larger.
Step 1 — Choose readable bins
numpy.histogram_bin_edges with bins="auto" picks a bin count suited to the amount of data. Its edges are rarely round numbers, though, and bin labels such as "47.3–61.9" are hard to read. Use the suggested width as a guide and round it to a friendly step:
import numpy as np
import pandas as pd
orders = pd.read_excel("orders.xlsx")
values = orders["Value"].to_numpy()
auto = np.histogram_bin_edges(values, bins="auto")
print(len(auto) - 1, "auto bins of width", round(auto[1] - auto[0], 1))
def nice_width(raw):
for step in [1, 2, 2.5, 5, 10, 20, 25, 50, 100, 200, 250, 500, 1000]:
if step >= raw:
return step
return raw
cap = 400
width = nice_width((np.percentile(values, 99) - values.min()) / 14)
edges = np.arange(0, cap + width, width)
print("width", width, "edges", edges[:4], "…", edges[-2:], "| values over", cap, ":", int((values >= cap).sum()))
The automatic choice gives 44 bins of about 28 each — far too many for a chart, and most of them empty stretches reaching out to the few very large orders. Basing the width on the 99th percentile instead, and aiming for about fourteen bins, gives a width of 25 — labels like "75–100" that anyone can read. Values above a cap of 400 will go in a single overflow bin rather than forcing a dozen empty bins between 400 and 1,250.
There is no single correct width; try two or three and keep the one where the shape is clear without every small bump looking meaningful. Once chosen, keep the same edges from one report to the next, so readers compare like with like.
Step 2 — Count values into the bins
np.histogram counts values per bin for a given set of edges. Add the overflow bin by counting separately, and build human-readable labels:
counts, _ = np.histogram(values[values < cap], bins=edges)
labels = [f"{lo:.0f}–{hi:.0f}" for lo, hi in zip(edges[:-1], edges[1:])]
labels.append(f"{cap} and over")
counts = np.append(counts, (values >= cap).sum())
hist = pd.DataFrame({"Order value": labels, "Orders": counts})
print(hist.to_string(index=False))
assert hist["Orders"].sum() == len(values)
Every bin includes its lower edge and excludes the upper one, except that np.histogram includes the upper edge in the last bin — which is why the overflow values are separated first with values < cap. The assertion confirms every order is counted exactly once. Bin Values into Ranges with pandas cut does the same with pd.cut when each row needs its bin label.
Step 3 — Chart the counts with openpyxl
Write the table and add a column chart. Three settings make it a histogram rather than a bar chart: gapWidth = 0 so the columns touch, no legend (there is one series), and visible axes:
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
wb = Workbook(); ws = wb.active; ws.title = "Distribution"
ws.append(["Order value", "Orders"])
for row in hist.itertuples(index=False):
ws.append([row[0], int(row[1])])
ws.column_dimensions["A"].width = 14
chart = BarChart()
chart.type = "col"
chart.gapWidth = 0
chart.title = f"Order values, September 2026 (n = {len(values)})"
chart.add_data(Reference(ws, min_col=2, min_row=1, max_row=len(hist) + 1), titles_from_data=True)
chart.set_categories(Reference(ws, min_col=1, min_row=2, max_row=len(hist) + 1))
chart.legend = None
chart.x_axis.title = "Order value"
chart.y_axis.title = "Number of orders"
chart.x_axis.delete = False
chart.y_axis.delete = False
chart.series[0].graphicalProperties.line.solidFill = "FFFFFF"
chart.width, chart.height = 20, 9
ws.add_chart(chart, "D2")
wb.save("order_histogram.xlsx")
print(hist.loc[hist["Orders"].idxmax()].tolist())
A thin white outline on each column keeps the bins distinguishable while they touch. The delete = False lines make Excel show the axes, which it otherwise hides for openpyxl 3.1 charts. The tallest bin is 75–100, with the gradual fall-off to the right typical of order values: many modest orders, a few large ones.
Step 4 — The same chart with xlsxwriter
In xlsxwriter the gap is a series option, and pandas can write the table in the same ExcelWriter:
with pd.ExcelWriter("order_histogram_xw.xlsx", engine="xlsxwriter") as writer:
hist.to_excel(writer, sheet_name="Distribution", index=False)
book, sheet = writer.book, writer.sheets["Distribution"]
col = book.add_chart({"type": "column"})
n = len(hist)
col.add_series({"name": "Orders", "categories": ["Distribution", 1, 0, n, 0], "values": ["Distribution", 1, 1, n, 1],
"gap": 0, "border": {"color": "white"}})
col.set_title({"name": "Order values, September 2026"})
col.set_x_axis({"name": "Order value"})
col.set_y_axis({"name": "Number of orders", "major_gridlines": {"visible": True}})
col.set_legend({"none": True})
col.set_size({"width": 760, "height": 340})
sheet.insert_chart("D2", col)
print("written", n, "bins")
xlsxwriter always writes its axes as visible, so there is no equivalent of the openpyxl axis setting. Add a Chart to an Excel File with xlsxwriter covers the other chart options.
Step 5 — A live histogram with FREQUENCY
When readers will paste new values into the workbook, the counts should update with them. Excel's FREQUENCY function counts values into bins given their upper edges, and in current Excel it returns all the counts at once as a dynamic array. Write the upper edges and the formula, and point the same chart at the formula results:
wb = Workbook(); ws = wb.active; ws.title = "Live"
ws.append(["Value", "", "Up to", "Orders"])
for v in values:
ws.append([float(v)])
last = len(values) + 1
for i, hi in enumerate(edges[1:], start=2):
ws.cell(row=i, column=3, value=float(hi))
ws.cell(row=len(edges) + 1, column=3, value="over")
ws["D2"] = f"=FREQUENCY(A2:A{last},C2:C{len(edges)})"
wb.save("live_histogram.xlsx")
print("bins:", len(edges) - 1, "+ overflow; formula:", ws["D2"].value)
FREQUENCY returns one more count than there are edges — the values above the last edge — which lands next to the "over" label automatically. Written by openpyxl the formula has no cached result, so the counts appear only when Excel recalculates the file; for spilling dynamic-array formulas written from Python, see Write Dynamic Array Formulas with Python. For reports that are regenerated anyway, the static counts from Step 2 are simpler and show correctly in every program.
Common pitfalls
- Expecting the native histogram chart. Neither library writes it; chart pre-counted bins.
- Gaps between columns. Set
gapWidth = 0(openpyxl) or"gap": 0(xlsxwriter). - Unreadable bin labels. Round the width to a friendly step and label ranges clearly.
- A few outliers stretching the axis. Use an overflow bin and say how many it holds.
- Unequal bin widths. Column heights mislead when bins differ in width; keep them equal.
- Missing axes in Excel. Set
delete = Falseon both axes of openpyxl charts.
Performance notes
np.histogram counts millions of values in milliseconds; only the table of counts goes into the workbook, so the chart stays small however large the data. The live FREQUENCY version stores every value in the sheet and recalculates on every change, which is fine for thousands of rows but slow for millions.
Conclusion
Pick bin edges with a round width, guided by numpy.histogram_bin_edges and a percentile rather than the extreme values, and send outliers to an overflow bin. Count with np.histogram, check the counts add up, label each bin as a range, and chart the table as a column chart with no gaps — gapWidth = 0 and visible axes in openpyxl, "gap": 0 in xlsxwriter. Use FREQUENCY with upper edges when the workbook must recount as its data changes.
Frequently asked questions
Can openpyxl or xlsxwriter create Excel's built-in histogram chart? No. Excel's histogram, box-and-whisker and waterfall charts use a newer chart format that neither library writes. Count the values into bins in Python and draw the counts as a column chart with no gap between the columns — it looks and reads the same.
How do I choose histogram bins? Start from numpy.histogram_bin_edges(values, bins="auto") to get a sensible number of bins, then round the width to a readable step such as 10, 25 or 50 so the bin labels are round numbers. Keep all bins the same width.
How do I remove the gaps between columns in an openpyxl chart? Set chart.gapWidth = 0 on the BarChart. In xlsxwriter, pass {"gap": 0} in the series options. Touching columns tell the reader the bins are continuous ranges rather than separate categories.
How do I handle a few very large values? Put them in a final overflow bin labelled, for example, "500 and over", instead of stretching the axis to include them. Say in the chart or a note how many values the overflow bin contains.
Related
- Up one level: Creating Charts in Excel with openpyxl — native Excel charts from Python.
- Create a Bar Chart in Excel with openpyxl — the chart type underneath.
- Detect Outliers in Excel Data with pandas — the values in the overflow bin.
- Embed a Matplotlib Chart in an Excel Report — when you need a fitted curve too.
More on charts & images
- How-toBuild a Dashboard Sheet with Charts from Multiple Tabs
- How-toBuild a Rolling 12-Month Chart on an Excel Dashboard
- How-toKeep Charts and Images When Filling an Excel Template
- How-toAdd a Chart to an Excel File with xlsxwriter
- How-toAdd Sparklines to an Excel Report with xlsxwriter
- How-toBuild a Waterfall Chart in Excel with Python