python-pptx
python-pptx reads and writes PowerPoint .pptx files directly, without PowerPoint or any Microsoft software installed. You open a presentation (a blank one or your company template), add slides from the template's layouts, and fill in placeholders, text boxes, tables, pictures, autoshapes, and charts, then save. Reading works the same way in reverse: iterate slides, iterate shapes, pull out text runs and images, which is why so much document-processing and retrieval tooling depends on it. It maps the Open XML file format onto Python objects fairly literally, so once you understand that a slide holds shapes, a shape may hold a text frame, a text frame holds paragraphs, and a paragraph holds runs, the API stops surprising you. All positions and sizes are English Metric Units, and the pptx.util module gives you Inches, Cm, Pt, and Emu to avoid doing that arithmetic yourself.
Still the only credible open-source way to build PowerPoint files from Python, and it does the core job well enough that an entire generation of reporting and document-parsing tools sits on top of it. Go in knowing it has been dormant since August 2024: pin the version, accept the missing slide operations, and plan a LibreOffice step if you need PDFs.
Use it if
- You generate decks from data on a schedule or per request: a weekly metrics deck, a per-customer proposal, a report built from a database query and returned as a file download
- You have a branded corporate template and want output that matches it, because slides added from that template's layouts inherit its fonts, colours, and placeholder positions
- You need to extract text or images from a corpus of existing decks for search indexing, migration, or feeding a retrieval pipeline
- You are doing find-and-replace templating: a designer builds the deck with placeholder tokens, and your code swaps in the real values while keeping every bit of formatting
- You need this to run on a Linux server with no Office licence and no GUI, which rules out COM automation entirely
- You need the project to be maintained. The last commit on GitHub is 6 August 2024 and the last PyPI release, 1.0.2, is 7 August 2024, with 447 open issues (534 counting PRs) and no visible triage since. It works, but nothing is going to be fixed
- You want PDFs or images out. There is no rendering engine here at all; converting a generated deck means shelling out to LibreOffice in headless mode, which is a separate large install and a separate set of fidelity problems
- You expect to manage slides as a list. There is no public API to delete a slide, reorder slides, or copy a slide from one presentation to another. Every answer online is direct manipulation of prs.slides._sldIdLst and the relationship table, using private attributes on a package nobody is maintaining
- Your deck depends on SmartArt, transitions, animations, or 3D charts. None of it is supported, and shapes the library does not model are preserved on read-write but cannot be created or edited
- You need something other than the layouts already in your template. Slides can only be created from a layout that exists in the file you opened, so the design work has to happen in PowerPoint first
- You want conversational text handling. PowerPoint splits a paragraph into runs at arbitrary boundaries, so a token like {{customer}} typed in one go can be stored across three runs and your replacement silently matches nothing
Setup reality
pip install python-pptx pulls lxml, Pillow, XlsxWriter, and typing-extensions, and needs Python 3.8 or newer. lxml is compiled, but wheels exist for every mainstream platform so it is normally a non-event; on an unusual architecture you are building libxml2 bindings. XlsxWriter is there because every chart embeds a real Excel worksheet inside the pptx, which also means chart data lives in two places and can drift. The real setup cost is the template. The bundled default template is 4:3 (10 by 7.5 inches), so a deck you generate with Presentation() and no arguments looks wrong on every modern screen until you set slide_width and slide_height yourself, and it carries generic Office styling. Any serious use starts by saving a .pptx from PowerPoint with your fonts, colours, and layouts, then opening that file instead. Expect to spend the first hour printing layout indexes and placeholder idx values, because those numbers vary per template and there is no way to guess them. Version 1.0.0 added a py.typed marker, so editor completion is decent, which helps a lot given how deep the object graph goes.
Patterns
Build a deck from the default templatecreate-first-deck
from pptx import Presentation
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[0]) # 0 = title slide
slide.shapes.title.text = "Q3 Review"
slide.placeholders[1].text = "Generated automatically"
prs.save("deck.pptx")Layout indexes are positions in whatever template you opened; 0 through 6 happen to be title, title-and-content, section header, two content, comparison, title only, and blank in the bundled default, but a corporate template will differ. Never hardcode an index without printing the layout names first.
Fix the 4:3 default before anyone sees itwidescreen-slides
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)The template bundled with python-pptx is 4:3 (10 by 7.5 inches), so every deck built from Presentation() with no argument comes out in a shape nobody has used since about 2012. Set the size before you place any shapes, since coordinates you have already computed do not move.
Discover the layouts and placeholders in your own templateinspect-a-template
from pptx import Presentation
prs = Presentation("brand-template.pptx")
for i, layout in enumerate(prs.slide_layouts):
print(i, layout.name)
for ph in layout.placeholders:
print(" idx", ph.placeholder_format.idx,
ph.placeholder_format.type, ph.name)Run this once per template and paste the output into a comment. Placeholder idx values are not sequential and are not the same as the position in slide.placeholders, which is the single most common source of a KeyError when moving code between templates.
Write multi-level bullets into a body placeholderbulleted-text
from pptx import Presentation
from pptx.util import Pt
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[1])
slide.shapes.title.text = "Findings"
tf = slide.placeholders[1].text_frame
tf.text = "Top level bullet" # replaces paragraph 0
p = tf.add_paragraph()
p.text = "Second level"
p.level = 1
p = tf.add_paragraph()
p.text = "Emphasised"
p.level = 2
p.font.bold = True
p.font.size = Pt(16)Setting text_frame.text deletes every paragraph except the first, so do it before add_paragraph, never after. level goes from 0 to 8 and the actual indent and bullet glyph come from the layout, not from this code. Check shape.has_text_frame before touching text_frame on shapes you did not create.
Place an image without distorting itadd-picture
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
# give exactly one dimension and aspect ratio is preserved
slide.shapes.add_picture("chart.png", Inches(1), Inches(1), width=Inches(6))
# from a file-like object works too
with open("logo.png", "rb") as f:
slide.shapes.add_picture(f, Inches(8), Inches(0.5), height=Inches(0.75))Passing both width and height stretches the image; passing neither uses the image's native size at its stored DPI, which is usually far too large. Identical images added repeatedly are stored once in the package, so a hundred-slide deck with the same logo does not carry a hundred copies.
Insert a table and set column widthsadd-table
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5]) # title only
rows, cols = 3, 2
table = slide.shapes.add_table(
rows, cols, Inches(1), Inches(2), Inches(8), Inches(1.5)
).table
table.columns[0].width = Inches(5)
table.columns[1].width = Inches(3)
table.cell(0, 0).text = "Metric"
table.cell(0, 1).text = "Value"
table.cell(1, 0).text = "Signups"
table.cell(1, 1).text = "1,204"add_table returns a graphic frame, not the table, hence the trailing .table. Rows and columns are fixed at creation: there is no add_row, so count your data first. Cell text formatting goes through cell.text_frame.paragraphs[0].runs, the same as any other text.
Add a native PowerPoint chart with its own dataadd-chart
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
data = CategoryChartData()
data.categories = ["East", "West", "Midwest"]
data.add_series("Q1", (19.2, 21.4, 16.7))
data.add_series("Q2", (22.3, 28.6, 15.2))
frame = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED,
Inches(2), Inches(2), Inches(6), Inches(4.5), data
)
chart = frame.chart
chart.has_legend = Trueadd_chart returns the graphic frame; the chart itself is frame.chart. Every chart embeds a real xlsx worksheet, which is why XlsxWriter is a dependency and why the file grows. Most 2D chart types are supported and 3D types are not. Use XyChartData or BubbleChartData for continuous X values.
Draw autoshapes and colour themshape-fill-and-color
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_SHAPE
from pptx.util import Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5])
shape = slide.shapes.add_shape(
MSO_SHAPE.ROUNDED_RECTANGLE,
Inches(1), Inches(2), Inches(2.5), Inches(1)
)
shape.text = "Step 1"
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(0x1F, 0x77, 0xB4)
shape.line.color.rgb = RGBColor(0xFF, 0xFF, 0xFF)fill.solid() has to be called before fore_color is available, otherwise you get an attribute error about the fill type. Setting an explicit rgb opts the shape out of theme colours, so it will not follow a template recolour later; use fore_color.theme_color if you want it to.
Pull every piece of text out of an existing deckextract-all-text
from pptx import Presentation
prs = Presentation("input.pptx")
for n, slide in enumerate(prs.slides, start=1):
for shape in slide.shapes:
if shape.has_text_frame:
for para in shape.text_frame.paragraphs:
text = "".join(run.text for run in para.runs)
if text.strip():
print(n, text)
if shape.has_table:
for row in shape.table.rows:
print(n, [c.text for c in row.cells])Grouped shapes are containers: shape.shapes has to be walked recursively or you lose everything inside a group. Speaker notes live separately on slide.notes_slide.notes_text_frame, and touching that property creates a notes slide if one does not exist.
Fill a designer-built template by replacing tokensfind-and-replace
def replace_text(prs, mapping):
for slide in prs.slides:
for shape in slide.shapes:
if not shape.has_text_frame:
continue
for para in shape.text_frame.paragraphs:
full = "".join(r.text for r in para.runs)
new = full
for k, v in mapping.items():
new = new.replace(k, str(v))
if new != full and para.runs:
para.runs[0].text = new
for r in para.runs[1:]:
r.text = ""Replacing run by run fails constantly because PowerPoint splits {{name}} across runs at spellcheck and language boundaries. Rebuilding the paragraph from run 0 fixes the matching but flattens mixed formatting within that paragraph onto the first run's style, so keep one token per paragraph in the template.
Remove a slide, since there is no API for itdelete-a-slide
R_NS = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id"
def delete_slide(prs, index):
id_list = prs.slides._sldIdLst
entries = list(id_list)
rId = entries[index].get(R_NS)
prs.part.drop_rel(rId)
id_list.remove(entries[index])This reaches into a private attribute and the relationship table, and it is the accepted answer because no public method exists. Reordering slides is the same trick with id_list.insert. Since the project is dormant the private names are unlikely to change, but this is the part of your code that will break first.
Return a generated deck from a web handlersave-to-memory
from io import BytesIO
from pptx import Presentation
def build_deck() -> bytes:
prs = Presentation("brand-template.pptx")
prs.slides.add_slide(prs.slide_layouts[0]).shapes.title.text = "Report"
buf = BytesIO()
prs.save(buf)
buf.seek(0)
return buf.getvalue()
# content type:
# application/vnd.openxmlformats-officedocument.presentationml.presentationsave accepts any file-like object, so nothing has to touch disk. Presentation() also accepts a file-like object, which lets you load a template from object storage. Forgetting buf.seek(0) is the reason a download arrives as a zero-byte file.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pptxgenjs | npm | Your generator can live in Node or the browser; it is actively released and produces decks client-side with no server round trip. |
| Spire.Presentation | PyPI | You need conversion to PDF or images, or support for shape types python-pptx does not model, and a commercial licence is acceptable. |
| markitdown | PyPI | You only want the text out of a deck as Markdown for indexing or an LLM, and never need to write a .pptx. |