In python-pptx, fonts live on runs, and paragraph.fontis the paragraph's default for its runs. Nearly every "my font isn't applying" question comes down to that one sentence. This page is the reference the library's docs are missing: where font properties actually live, why your setting sometimes doesn't take, and working code for the cases people search for.
The 30-second answer
from pptx.util import Pt
from pptx.dml.color import RGBColor
p = text_frame.paragraphs[0]
# Set the default for every run in this paragraph:
p.font.size = Pt(18)
p.font.bold = True
p.font.name = "Calibri"
p.font.color.rgb = RGBColor(0x11, 0x18, 0x27)
# Or set an individual run (wins over the paragraph default):
run = p.add_run()
run.text = "just this bit"
run.font.italic = TrueThe inheritance chain (why "None" is an answer, not a bug)
Reading p.font.size and getting Nonedoesn't mean 0pt — it means inherited. Effective formatting resolves in this order, first explicit value wins:
- Run —
run.font - Paragraph default —
paragraph.font - Placeholder / layout / master — whatever the template defines
- Theme defaults — the deck's theme fonts
python-pptx only reports what is explicitly set at that level. It does not compute the effective value for you — if you need to know what the text will actually look like, you have to walk up the chain yourself (or render it; more on that below).
The classic trap: tf.text = ... then wondering where your font went
Assigning text_frame.text (or paragraph.text) replaces the content with a single fresh run carrying no direct formatting. So this sequence silently loses the size:
run = p.add_run()
run.text = "Styled"
run.font.size = Pt(24) # styled run exists...
p.text = "Replaced" # ...and is now gone, with its formattingSet text first, then style — or style at the paragraph level, which survives new runs being added since it's the paragraph's default rather than a property of any one run.
Styling every run in an existing text frame
When you load a template deck and want to restyle whatever text is there, iterate all three levels — and remember each shape only has a text frame if has_text_frame is true:
from pptx.util import Pt
def set_font(text_frame, *, size=None, name=None, bold=None):
for paragraph in text_frame.paragraphs:
for run in paragraph.runs:
if size is not None:
run.font.size = Pt(size)
if name is not None:
run.font.name = name
if bold is not None:
run.font.bold = bold
for shape in slide.shapes:
if shape.has_text_frame:
set_font(shape.text_frame, size=14, name="Calibri")Setting the run level (not the paragraph level) is deliberate here: it wins over anything, so the result is deterministic regardless of what the template defined.
Try it — edit and render this in your browser
The fastest way to internalize the run-vs-paragraph split is to see it. This sample runs in SlideForge's sandbox (standard python-pptx inside def build(prs): — the runner supplies the Presentation and saves it). Change a Pt()or a color and hit Run; the render comes back in about a second, which python-pptx itself can't do — it writes XML and has no renderer.
Where the hand-styling tax ends
Everything above is real and works. It is also the tax python-pptx charges for every slide: fonts per run, coordinates per box, and no way to see the result without opening PowerPoint. If what you're building is business slides from structured data, SlideForge renders a typed intent into a themed, editable .pptx in under a second — typography handled by the theme — and its code moderuns your own python-pptx with PDF and full-resolution image export, the two outputs the library can't produce.
Related: python-pptx limitations we solved · Generate PowerPoint in Python