power-pptx

Charts

A chart is three objects: a ChartData describing the numbers, a graphic-frame shape holding it on the slide, and a Chart exposing everything you can style. This page walks the whole surface; the terse member lists live inAPI: Charts.

Creating a chart

add_chart returns the graphic frame shape, not the chart — reach the chart through its .chart property. Data is copied into an embedded XLSX workbook at insert time, which is what lets a recipient click "Edit Data" in PowerPoint.

from power_pptx import Presentation
from power_pptx.chart.data import CategoryChartData
from power_pptx.enum.chart import XL_CHART_TYPE
from power_pptx.util import Inches

prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])   # Title Only
slide.shapes.title.text = "Revenue"

# 1. Describe the data
chart_data = CategoryChartData()
chart_data.categories = ["Q1", "Q2", "Q3", "Q4"]
chart_data.add_series("Revenue", (1.2, 1.8, 2.4, 3.1))

# 2. Place it. add_chart returns the GraphicFrame *shape*, not the chart.
x, y, cx, cy = Inches(1), Inches(1.5), Inches(8), Inches(5)
graphic_frame = slide.shapes.add_chart(
    XL_CHART_TYPE.COLUMN_CLUSTERED, x, y, cx, cy, chart_data
)

# 3. Style it through the Chart object hanging off the frame
chart = graphic_frame.chart
chart.has_title = False

prs.save("revenue.pptx")

Choosing a chart type

The first argument to add_chart is an XL_CHART_TYPE member. Names follow the Excel object model: a family prefix, then the variant —_CLUSTERED (series side by side), _STACKED,_STACKED_100 (normalised), _MARKERS, _EXPLODED. The full member list is on API: Enumerations.

from power_pptx.enum.chart import XL_CHART_TYPE

XL_CHART_TYPE.COLUMN_CLUSTERED     # vertical bars, series side by side
XL_CHART_TYPE.COLUMN_STACKED_100   # vertical bars, normalised to 100%
XL_CHART_TYPE.BAR_CLUSTERED        # horizontal bars
XL_CHART_TYPE.LINE_MARKERS         # line with a marker per point
XL_CHART_TYPE.PIE                  # single series only
XL_CHART_TYPE.DOUGHNUT
XL_CHART_TYPE.AREA_STACKED
XL_CHART_TYPE.XY_SCATTER           # needs XyChartData
XL_CHART_TYPE.BUBBLE               # needs BubbleChartData

print(len(list(XL_CHART_TYPE)))    # 73 members

The chart type also decides which data class you need: everything category-based takesCategoryChartData, XY_SCATTER* takes XyChartData, andBUBBLE* takes BubbleChartData. Pie, doughnut and their variants plot a single series — extra series you add are ignored by PowerPoint.

Categories and series

CategoryChartData holds one list of categories shared by every series. Eachadd_series(name, values) call appends a series whose values line up positionally with the categories, so all series must be the same length as the category list. Series are plotted, coloured and legended in the order they were added.

chart_data = CategoryChartData()
chart_data.categories = ["Q1", "Q2", "Q3", "Q4"]
chart_data.add_series("ARR", (100, 130, 155, 182))
chart_data.add_series("Pipeline", (210, 240, 260, 305))

# Categories can also be appended one at a time, and may be dates or
# numbers rather than strings.
from datetime import date

monthly = CategoryChartData()
for month_end in (date(2026, 1, 31), date(2026, 2, 28), date(2026, 3, 31)):
    monthly.add_category(month_end)
monthly.add_series("MRR", (110, 128, 141))
monthly.categories.are_dates          # True -> PowerPoint uses a date axis

# Two-level categories: sub-categories under a parent
regional = CategoryChartData()
emea = regional.add_category("EMEA")
emea.add_sub_category("UK")
emea.add_sub_category("DE")
amer = regional.add_category("AMER")
amer.add_sub_category("US")
amer.add_sub_category("CA")
regional.add_series("Bookings", (4.1, 3.2, 9.8, 1.7))   # one value per leaf
regional.categories.depth             # 2
regional.categories.leaf_count        # 4

Category labels do not have to be strings. Feed date ordatetime objects and categories.are_dates becomes true, which makes PowerPoint render a real time-scale axis (chart.category_axis comes back as aDateAxis). Numeric labels behave the same way viacategories.are_numeric.

Scatter and bubble charts have no shared category list, so their data classes build each series point by point instead:

from power_pptx.chart.data import XyChartData, BubbleChartData

xy = XyChartData()
trial = xy.add_series("Trial accounts")
trial.add_data_point(1.0, 2.5)         # (x, y)
trial.add_data_point(2.0, 4.1)
trial.add_data_point(3.0, 3.3)

bubbles = BubbleChartData()
accounts = bubbles.add_series("Accounts")
accounts.add_data_point(1.0, 2.0, 10)  # (x, y, size)
accounts.add_data_point(2.5, 3.5, 40)

Titles, axes and gridlines

chart.value_axis and chart.category_axis return axis objects for the chart's primary axes. On a scatter or bubble chart category_axis is the X axis; on a pie or doughnut chart it raises ValueError, because there are no axes at all.

Both chart.chart_title and axis.axis_title aredestructive reads: touching them adds the title element if it is missing. Use the matching has_title property when you only want to test.

from power_pptx.enum.chart import XL_TICK_MARK
from power_pptx.util import Pt

# Chart title. has_title is the non-destructive test; touching
# chart_title creates the title element if it is missing.
chart.has_title = True
chart.chart_title.text_frame.text = "Revenue vs Cost"

value_axis = chart.value_axis
value_axis.minimum_scale = 0.0
value_axis.maximum_scale = 4.0
value_axis.major_unit = 1.0
value_axis.has_major_gridlines = True
value_axis.has_title = True
value_axis.axis_title.text_frame.text = "$M"

category_axis = chart.category_axis
category_axis.has_major_gridlines = False
category_axis.major_tick_mark = XL_TICK_MARK.NONE
category_axis.tick_labels.font.size = Pt(10)

# Axis lines and gridlines are ordinary DrawingML
value_axis.format.line.color.rgb = "#D4D4D8"
value_axis.major_gridlines.format.line.color.rgb = "#EEEEF2"

Number formats

Tick labels and data labels are formatted with Excel format strings — "$"#,##0,0.0%, 0.00E+00. That syntax is easy to typo and hard to read back, sopower_pptx.formats generates the common ones for you. Each helper returns a plainstr, so the result round-trips exactly like a hand-written format and stays editable in Excel's "Format Cells" dialog.

from power_pptx.formats import currency, percent, decimal, thousands, scientific, date

currency("$")                 # '"$"#,##0'
currency("£", decimals=2)     # '"£"#,##0.00'
currency("USD ", decimals=0)  # '"USD "#,##0'   -- multi-character codes work
percent(decimals=1)           # '0.0%'      (0.27 renders as 27.0%)
decimal(decimals=2)           # '#,##0.00'
decimal(decimals=0, thousands_sep=False)   # '0'
thousands()                   # '#,##0'
scientific(decimals=2)        # '0.00E+00'
date()                        # 'yyyy-MM-dd'
date("MMM YYYY")              # 'MMM yyyy'

date() takes the upper-case tokens authors normally write (YYYY, DD, HH, SS) and lowercases the ones Excel expects lowercase. M/MM/MMM/MMMM stay capitalised on purpose — in Excel's grammar lowercase m means minutes, not months.

Assign the result anywhere a number format is accepted:

from power_pptx.formats import currency, percent, thousands

# Value-axis tick labels
chart.value_axis.tick_labels.number_format = currency("$", decimals=0)
chart.value_axis.tick_labels.number_format_is_linked = False

# Data labels
plot = chart.plots[0]
plot.has_data_labels = True
plot.data_labels.number_format = percent(decimals=1)
plot.data_labels.number_format_is_linked = False

# Or bake the format into the source data, so the embedded workbook and
# the labels agree from the start
data = CategoryChartData(number_format=thousands())
data.categories = ["North", "South", "East"]
data.add_series("Units", (12000, 9400, 15600))
data.add_series("Revenue", (1.2e6, 9.4e5, 1.56e6),
                number_format=currency("$", decimals=0))

Set number_format_is_linked = False whenever you assign a format. While it is true, PowerPoint re-reads the format from the source cell in the embedded workbook and your assignment appears to be ignored.

Data labels

Labels are switched on per plot, not per chart. plot.has_data_labels = True turns them on, and plot.data_labels configures what each one shows. Individual points can then override the shared settings through point.data_label.

from power_pptx.enum.chart import XL_LABEL_POSITION
from power_pptx.util import Pt

plot = chart.plots[0]
plot.has_data_labels = True

labels = plot.data_labels
labels.show_value = True
labels.show_category_name = False
labels.show_series_name = False
labels.show_percentage = False        # pie/doughnut: show slice share
labels.position = XL_LABEL_POSITION.OUTSIDE_END
labels.font.size = Pt(9)
labels.font.bold = True

# Override one point's label
point = chart.series[0].points[0]
point.data_label.has_text_frame = True
point.data_label.text_frame.text = "best quarter"

On a dense bar or column chart the labels will collide, and PowerPoint has no reflow of its own. collision_strategy is a fork-added shortcut that applies the usual manual fixes — shrink the label type, thicken the bars — in one assignment. It is write-only, because the state it sets is split across the label font and the plot's gap width.

labels.collision_strategy = "auto"      # write-only

# "shrink"  -> 8 pt labels, bar geometry untouched
# "compact" -> 8 pt labels and gapWidth=60 (thicker bars) unconditionally
# "auto"    -> 8 pt labels, plus gapWidth=60 on bar/column plots with
#              five or more categories AND more than one series

These are heuristics, not real collision avoidance. If labels genuinely need to be re-laid out around their bars, render the figure with Plotly and embed it viapower_pptx.design.figures.add_plotly_figure instead.

Legends and chart-wide type

Assigning has_legend = False removes the legend element outright, taking its position and font settings with it — so set the flag first, then style.chart.legend is None until then.

from power_pptx.enum.chart import XL_LEGEND_POSITION
from power_pptx.util import Pt

chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False   # don't let the legend shrink the plot
chart.legend.font.size = Pt(10)

# Chart-wide text defaults (inherited by title, labels, tick labels, legend)
chart.font.name = "Inter"
chart.font.size = Pt(11)

Replacing the data

chart.replace_data(chart_data) swaps in new categories and values, rewriting both the chart XML and the embedded workbook so "Edit Data" keeps working. Formatting — palette, layout, axis scales, label settings — survives. This is the supported way to refresh a chart that lives in a template deck rather than rebuilding the slide.

from power_pptx import Presentation
from power_pptx.chart.data import CategoryChartData

prs = Presentation("revenue.pptx")
chart = prs.slides[0].shapes[1].chart

fresh = CategoryChartData()
fresh.categories = ["Q1", "Q2", "Q3", "Q4"]
fresh.add_series("Revenue", (1.4, 1.9, 2.6, 3.6))

chart.replace_data(fresh)     # rewrites the XML *and* the embedded workbook
prs.save("revenue.pptx")

A second value axis

chart.secondary_value_axis adds a right-hand value axis (plus the hidden category axis it crosses) on first access, and returns the existing one afterwards. It raisesValueError on chart types with no value axis, such as pie.

from power_pptx.formats import percent

secondary = chart.secondary_value_axis      # creates it on first access
secondary.tick_labels.number_format = percent(decimals=0)
secondary.tick_labels.number_format_is_linked = False

chart.series[1].axis_group = "secondary"

The axis group belongs to the plot, not the series:series.axis_group = "secondary" moves the whole plot that series belongs to. A chart built by add_chart has exactly one plot, so on a freshly-built chart this moves every series onto the secondary axis at once. Genuine two-scale combo charts have two plots and therefore come from a template deck or an imported slide; there, movingchart.plots[1]'s series does what you expect. Moving a series back to"primary" is not supported.

Palettes and recolouring fork

# Single entry point — dispatches per chart type
chart.recolour("vibrant")      # per-point on pie/doughnut, per-series otherwise
chart.recolour(["#4F9DFF", "#7FCFA1", "#F7B500"])   # explicit colours work too

# Built-ins: modern, classic, editorial, vibrant,
#            monochrome_blue, monochrome_warm

# Dark-deck styling: pin axis lines + gridlines
chart.apply_dark_theme(text="#E5E7EB", line="#374151")
chart.line_color = "#9CA3AF"

chart.apply_palette(...) on a pie/doughnut warns and routes throughcolor_by_category — call chart.recolour(...) directly to silence it. Palettes wrap when the chart has more series than colours, and thechart_style index is left untouched, so only the per-series fill is overridden.

Quick layouts fork

A quick layout is an opinionated combination of the title / legend / axis-title / gridline toggles above — the same thing Excel's "Quick Layout" gallery does, in one call.

chart.apply_quick_layout("minimal")       # one of ten presets

from power_pptx.chart.quick_layouts import layout_names
print(layout_names())   # title_legend_right, title_legend_bottom, ...

# A dict spec composes with whatever is already set — missing keys are
# left alone, and charts without axes silently skip the axis keys.
chart.apply_quick_layout({
    "has_title": True,
    "title_text": "ARR ($M)",
    "has_legend": True,
    "legend_position": "bottom",
    "category_axis": {"has_major_gridlines": False},
    "value_axis": {"has_major_gridlines": True, "tick_labels": True},
})

Per-series fills

chart.series[i].format.fill is an ordinary FillFormat, so gradients and patterns work per series with no chart-specific shim.

series = chart.series[0]
series.format.fill.gradient(kind="linear", angle=0)
series.format.fill.gradient_stops.replace([
    (0.0, "#0B5CFF"),
    (1.0, "#00D4FF"),
])

# Pattern fill
from power_pptx.enum.dml import MSO_PATTERN_TYPE
series.format.fill.patterned()
series.format.fill.pattern = MSO_PATTERN_TYPE.PERCENT_40

Note: MSO_PATTERN_TYPE.ERCENT_40 is the upstream typo and raisesDeprecationWarning — use PERCENT_40.

Trendlines, error bars and scale

Trendlines and error bars are available on bar, line, scatter, area and bubble series — the types whose schema permits them. Pie and radar series do not.

s = chart.series[0]
s.trendlines.add("linear", show_equation=True, show_r_squared=True)
s.trendlines.add("poly", order=3)
s.trendlines.add("movingAvg", period=2)

chart.series[1].error_bars.percentage(5)
# also: .fixed(0.5) .standard_deviation(1) .standard_error() .custom(plus, minus)

chart.value_axis.log_base = 10        # 2..1000; None restores a linear axis
chart.plots[0].gap_width = 60         # bar/column: thicker bars
chart.plots[0].overlap = -10

Horizontal bar reading order

Horizontal bar charts (BAR_*) default to top-to-bottom reading order (reverse_order=True), so the first category you feed renders at the top. Column charts keep left-to-right ordering. Restore legacy bottom-up ordering with:

chart.category_axis.reverse_order = False