How-to
Formatting And Charting Excel Reports With PythonDeep dive

Create a Step Chart in Excel with Python

Draw values that change at specific moments and then hold — prices, interest rates, headcount, stock levels — as a step chart in Excel from Python: why a normal line chart misleads with diagonal ramps, how to double up the change points with pandas, build the chart as an openpyxl scatter chart on a real date axis, carry the last value to the end of the period, and compare several series.

Some numbers do not drift from one value to the next; they jump. A list price is 4.50 until 15 March and 4.80 from then on. A base rate holds for months and then moves a quarter point overnight. Headcount, contract value and stock on a shelf after a delivery behave the same way. A normal line chart joins each point to the next with a sloping line, which suggests the price was 4.65 on 1 March — a value it never had. A step chart draws such data honestly: flat lines while the value holds and vertical jumps where it changes. Excel has no step chart type, but a scatter chart with straight lines drawn through doubled-up points produces one exactly. This page prepares the data with pandas, builds the chart with openpyxl on a real date axis, extends the final value to the end of the period, compares two series, and renders the result to check it. It belongs to Creating Charts in Excel with openpyxl.

Prerequisites

Bash
pip install openpyxl pandas pillow

LibreOffice (soffice) and pdftoppm are used only to render the chart for checking. The data is a price history: the dates on which a product's list price changed and the new price from each date, plus the same for a competitor.

Step 1 — Why a line chart misleads

Python
import pandas as pd

ours = pd.DataFrame({"date": pd.to_datetime(["2026-01-01", "2026-03-15", "2026-06-01", "2026-08-20"]),
                     "price": [4.50, 4.80, 4.65, 5.10]})
theirs = pd.DataFrame({"date": pd.to_datetime(["2026-01-01", "2026-02-10", "2026-05-05", "2026-09-01"]),
                       "price": [4.70, 4.60, 4.95, 4.85]})
PERIOD_END = pd.Timestamp("2026-09-30")

on_1_march = ours.set_index("date")["price"].reindex(pd.date_range("2026-01-01", "2026-09-30")).ffill()["2026-03-01"]
interp = ours.set_index("date")["price"].reindex(pd.date_range("2026-01-01", "2026-09-30")).interpolate()["2026-03-01"]
print(f"price on 1 March: actual {on_1_march:.2f}, what a line chart implies {interp:.3f}")

On 1 March the price was still 4.50 — the change came on the 15th. A line chart joining 1 January at 4.50 to 15 March at 4.80 passes through 4.74 on 1 March, which never happened. For prices, rates and levels the question readers ask is "what was it on date X?", and only a step chart answers it correctly.

Line chart or step chart for a price history A line chart joins the change points with sloping lines, implying intermediate prices that never existed, such as 4.74 on 1 March. A step chart holds each price flat until the next change and jumps vertically, so the value on any date can be read directly. Line chart slopes between changes implies 4.74 on 1 March last value ends at last change misleading for levels Step chart flat while the value holds vertical jump at each change extended to period end reads 4.50 on 1 March

Step 2 — Double up the change points

A step is two points at the same date: the old value and the new value. Build the step series by repeating each change date with the previous price, then add a final point at the end of the period so the last price is drawn for as long as it applied:

Python
def steps(df, end):
    rows = []
    for i, (date, price) in enumerate(zip(df["date"], df["price"])):
        if i:
            rows.append((date, df["price"].iloc[i - 1]))      # the old value up to the change
        rows.append((date, price))                              # the new value from the change
    rows.append((end, df["price"].iloc[-1]))                    # hold the last value to the period end
    return pd.DataFrame(rows, columns=["date", "price"])

ours_steps, theirs_steps = steps(ours, PERIOD_END), steps(theirs, PERIOD_END)
print(ours_steps.to_string(index=False))

Four price changes become eight points: the first price, then for each later change one point with the old price and one with the new price on the same date, and a final point on 30 September. Joined with straight lines, consecutive points with the same date draw the vertical jump and consecutive points with the same price draw the flat segment. Forgetting the last point is the most common mistake — the chart then stops at the last change, as if the price did not exist afterwards.

From change points to step points Four change dates with their new prices become eight chart points. Each change after the first adds a point with the old price on the change date before the new price, and a final point holds the last price until 30 September. A B 1 date price 2 01 Jan 4.50 3 15 Mar 4.80 4 01 Jun 4.65 A B 1 date price 2 01 Jan 4.50 3 15 Mar 4.50 4 15 Mar 4.80 5 01 Jun 4.80 6 01 Jun 4.65 before after steps()

Step 3 — A scatter chart on a date axis

A line chart in Excel treats its categories as evenly spaced labels, so two points on the same date would sit side by side. A scatter chart places points by their x value, so both points of a step land on the same date. Write the step points and draw them with straight lines and no markers:

Python
from openpyxl import Workbook
from openpyxl.chart import Reference, ScatterChart, Series
from openpyxl.drawing.line import LineProperties

wb = Workbook()
ws = wb.active
ws.title = "Prices"
ws.append(["Our date", "Our price", "", "Their date", "Their price"])
for i in range(max(len(ours_steps), len(theirs_steps))):
    row = [None] * 5
    if i < len(ours_steps):
        row[0], row[1] = ours_steps["date"][i].to_pydatetime(), ours_steps["price"][i]
    if i < len(theirs_steps):
        row[3], row[4] = theirs_steps["date"][i].to_pydatetime(), theirs_steps["price"][i]
    ws.append(row)
for col in "AD":
    for cell in ws[col][1:]:
        cell.number_format = "dd mmm"

chart = ScatterChart()
chart.title = "List price, ours and competitor's"
chart.style = 13
for x_col, y_col, colour, name in [(1, 2, "1F6FEB", "Ours"), (4, 5, "D4A72C", "Competitor")]:
    count = len(ours_steps) if x_col == 1 else len(theirs_steps)
    xs = Reference(ws, min_col=x_col, min_row=2, max_row=1 + count)
    ys = Reference(ws, min_col=y_col, min_row=2, max_row=1 + count)
    s = Series(ys, xs, title=name)
    s.marker.symbol = "none"
    s.smooth = False
    s.graphicalProperties.line = LineProperties(solidFill=colour, w=31750)
    chart.series.append(s)
chart.x_axis.number_format = "mmm"
chart.x_axis.scaling.min = pd.Timestamp("2026-01-01").to_julian_date() - 2415018.5
chart.x_axis.scaling.max = PERIOD_END.to_julian_date() - 2415018.5
chart.x_axis.majorUnit = 31
chart.y_axis.scaling.min, chart.y_axis.scaling.max = 4.4, 5.2
chart.y_axis.number_format = "0.00"
chart.x_axis.delete = chart.y_axis.delete = False
chart.legend.position = "b"
chart.width, chart.height = 18, 9
ws.add_chart(chart, "A12")
wb.save("step_chart.xlsx")
print("x axis from", chart.x_axis.scaling.min, "to", chart.x_axis.scaling.max)

The x values are real dates, written as Excel date serials with a month format, so the axis spaces them by time: the step on 15 March sits halfway through the month. The axis bounds are set as serial numbers — 46023 for 1 January 2026 and 46295 for 30 September — computed here from pandas timestamps, and a major unit of 31 days gives one tick, labelled with its month, per month — a unit of 30 would label two ticks Jan, because 31 January is still January. smooth = False and marker.symbol = "none" keep the lines straight and clean. Each series has its own x and y columns, so the two price histories can change on different dates. Dates on chart axes in general are covered in Use a Date Axis in an Excel Chart with openpyxl.

Step 4 — Render and check

Python
import subprocess
from PIL import Image

subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "step_chart.xlsx"], check=True, capture_output=True)
subprocess.run(["pdftoppm", "-png", "-r", "80", "-f", "1", "-l", "1", "step_chart.pdf", "step"], check=True)
img = Image.open("step-1.png").convert("RGB")
counts = {colour: n for n, colour in img.getcolors(maxcolors=2_000_000)}
for label, hexcode in [("ours (blue)", "1F6FEB"), ("competitor (gold)", "D4A72C")]:
    rgb = tuple(int(hexcode[i:i + 2], 16) for i in (0, 2, 4))
    print(f"{label:18} {counts.get(rgb, 0):>6} px")

Both series appear in the render, and the chart sits below the data at A12 so it prints whole on the first page rather than being cut at the page edge. Looked at as an image, the blue line holds at 4.50 until mid-March, jumps to 4.80, steps down to 4.65 in June and up to 5.10 in late August, and runs flat to the end of September; the competitor's line steps at its own dates, and the chart shows at a glance that they were cheaper in February and dearer from May until our August increase.

The two price histories Our price is 4.50 from January, 4.80 from 15 March, 4.65 from 1 June and 5.10 from 20 August. The competitor's is 4.70 from January, 4.60 from 10 February, 4.95 from 5 May and 4.85 from 1 September. Jan ours 4.50 · theirs 4.70 10 Feb theirs → 4.60 15 Mar ours → 4.80 5 May theirs → 4.95 1 Jun ours → 4.65 20 Aug ours → 5.10 1 Sep theirs → 4.85

Variations

The same doubled-points technique covers related charts. If the history does not start at the beginning of the period — the first recorded change is in March, but the price before it is known — add a first point at the period start with that opening value, just as the last point extends to the period end. For many items, such as the stock level of each product, build the step points per item with a groupby and give each its own pair of columns, or one chart per item as in Create One Chart per Group with openpyxl. And when the analysis needs the value on every day — to average a price over a quarter, say — expand the change points with a daily reindex and ffill(), as the first code block did to look up 1 March.

Common pitfalls

  • Using a line chart. Its sloping lines imply values that never existed.
  • Category axes. Two points on one date need a scatter chart's value axis.
  • No final point. Add the period end so the last value is drawn.
  • Smoothed lines or markers. Turn both off for clean steps.
  • Axis bounds as dates. Set them as Excel serial numbers.

Conclusion

For values that hold until they change, build a step chart: duplicate each change date with the previous value and the new value, add a point at the end of the period with the last value, and draw the points as a scatter chart with straight lines, no markers and no smoothing, on a date axis with bounds set as Excel serial numbers. Give each series its own x and y columns so histories with different change dates can share the chart.

Frequently asked questions

How do I make a step chart in Excel? Duplicate each change point so the old and new values share the change date, then plot the points as a scatter chart with straight lines.

Why use a scatter chart rather than a line chart for steps? A line chart spaces categories evenly, so two points on the same date sit apart; a scatter chart places them at the same x value, drawing a vertical jump.

Why does my step chart stop at the last change? Add a final point at the end of the period with the last value so the line continues.

How do I set date axis limits on an openpyxl scatter chart? Set x_axis.scaling.min and max to Excel date serial numbers, for example 46023 for 1 January 2026, and use a date number format on the axis.

More on charts & images

More on charts & images →