Set Fonts and Styles with python-docx

run.font.name = "Arial" runs without error, you save the file, open it in Word — and the font is still Calibri. Or you set a Chinese font name and the CJK characters still render in the default fallback face. Both symptoms share the same root cause: run-level formatting only wins when no style higher in the inheritance chain explicitly overrides it, and the w:eastAsia XML attribute that controls East-Asian character rendering is not exposed through the python-docx high-level API.

This is a focused fix for the font-and-style layer of the broader document-generation workflow in Automating Word Document Creation. It explains the root cause, provides a minimal diagnostic to confirm it, then shows four targeted fixes: run-level font properties for Latin text, the w:eastAsia oxml workaround for CJK characters, defining a reusable named character style, and modifying the Normal paragraph style's font and spacing globally.


Root Cause

Word resolves the final rendered font through a precedence chain:

  1. Run direct formatting — properties set on a Run object (run.font.name, run.bold, etc.)
  2. Character style — a named character style applied to the run (run.style = ...)
  3. Paragraph style — the style of the paragraph the run belongs to (Normal, Body Text, etc.)
  4. Document defaults — the document's default font and size in word/settings.xml

The renderer walks this chain from the top and uses the first level that sets an explicit value for the character range being drawn — which is why a run property can appear to have "no effect" when a higher-priority level was never the one overriding it in the first place.

Font resolution precedence chain for Latin vs CJK ranges Word walks four precedence levels top to bottom: run direct formatting, character style, paragraph style, document default. The Latin range resolves at the run level because run.font.name writes w:ascii and w:hAnsi. The CJK range finds no match at levels 1 to 3 and falls through to the document default fallback face, because python-docx never writes w:eastAsia. Latin range w:ascii / hAnsi CJK range w:eastAsia 1 · Run direct formatting run.font.name writes w:ascii, w:hAnsi — but not w:eastAsia 2 · Character style run.style = doc.styles["BrandHighlight"] 3 · Paragraph style Normal, Body Text, Heading 1 … 4 · Document default settings.xml fallback face (SimSun / Heiti SC) resolved resolved at this level no match — falls through to the next level

Setting run.font.name = "Arial" writes w:ascii="Arial" and w:hAnsi="Arial" to the run's <w:rFonts> element. This correctly overrides the style chain for the Latin character range. However:

  • If you set run.font.name but the paragraph style defines the same font explicitly, some renderers still show the style value (the run direct-formatting flag must be set, not just the value).
  • The w:eastAsia attribute — which Word uses for Han, Hiragana, Katakana, Hangul, and related ranges — is never written by the python-docx API when you assign run.font.name. So CJK characters always fall through to the style or document default, regardless of what you put in run.font.name.

Minimal Diagnostic

Run this to see exactly what XML python-docx produces for a run with font.name assigned:

# pip install python-docx lxml
from pathlib import Path
from docx import Document
from lxml import etree

OUTPUT = Path("output/diag_fonts.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)

doc  = Document()
para = doc.add_paragraph()
run  = para.add_run("Hello, 世界 — Latin and CJK mixed")
run.font.name = "Noto Sans SC"   # intended CJK font
run.font.size = __import__("docx.shared", fromlist=["Pt"]).Pt(12)

doc.save(OUTPUT)

# Re-open and inspect the raw XML of the first run
doc2    = Document(OUTPUT)
first_r = doc2.paragraphs[0].runs[0]
print(etree.tostring(first_r._r, pretty_print=True).decode())

Expected (broken) output — notice w:eastAsia is absent:

<w:r xmlns:w="...">
  <w:rPr>
    <w:rFonts w:ascii="Noto Sans SC" w:hAnsi="Noto Sans SC"/>
    <w:sz w:val="24"/>
    <w:szCs w:val="24"/>
  </w:rPr>
  <w:t xml:space="preserve">Hello, 世界 — Latin and CJK mixed</w:t>
</w:r>

w:ascii and w:hAnsi are set, but w:eastAsia is missing. Word falls back to its default CJK face (SimSun on Windows, Heiti SC on macOS) for the CJK codepoints.


Fix 1 — Run-Level Font Properties (Latin Text)

For Latin scripts, setting run.font.name and run.font.size is sufficient as long as the paragraph's named style does not also set an explicit font. Add bold, italic, and color on the same object:

# pip install python-docx
from pathlib import Path
from docx import Document
from docx.shared import Pt, RGBColor

OUTPUT = Path("output/word/styled_run.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)

doc  = Document()
para = doc.add_paragraph()
run  = para.add_run("Critical notice — Latin text only")

run.font.name      = "Georgia"                       # Latin typeface
run.font.size      = Pt(13)                          # explicit size in points
run.bold           = True                            # shortcut for run.font.bold = True
run.font.italic    = True
run.font.color.rgb = RGBColor(0xC0, 0x39, 0x2B)     # dark red (hex RGB)

try:
    doc.save(OUTPUT)
    print(f"Saved: {OUTPUT}")
except OSError as exc:
    print(f"Save failed: {exc}")

run.bold and run.font.bold are equivalent. Prefer run.font.bold for consistency with other font properties. Setting a value to None explicitly resets it to inherit from the style chain; setting False explicitly suppresses bold even if the style requests it.


Fix 2 — East-Asian Font via oxml (the w:eastAsia Workaround)

python-docx has no run.font.east_asian_name property. Set it by reaching into the underlying lxml element directly:

# pip install python-docx lxml
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Pt

OUTPUT    = Path("output/word/cjk_font.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)

CJK_FONT   = "Noto Sans SC"
LATIN_FONT = "Arial"

doc  = Document()
para = doc.add_paragraph()
run  = para.add_run("Hello, 世界 — mixed Latin and CJK text.")

# Set the Latin/ASCII font via the high-level API
run.font.name = LATIN_FONT
run.font.size = Pt(12)

# Add w:eastAsia to the rFonts element — the missing step
r_pr    = run._r.get_or_add_rPr()       # <w:rPr> element, created if absent
r_fonts = r_pr.get_or_add_rFonts()      # <w:rFonts> element, created if absent
r_fonts.set(qn("w:eastAsia"), CJK_FONT) # write the attribute python-docx omits

try:
    doc.save(OUTPUT)
    print(f"Saved: {OUTPUT}")
except OSError as exc:
    print(f"Save failed: {exc}")

After this fix, the XML for the run looks like:

<w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="Noto Sans SC"/>

Word now selects "Noto Sans SC" for the CJK codepoints (U+4E00–U+9FFF and related ranges) and "Arial" for the Latin characters. Both fonts must be installed on the machine that opens the document, or Word will substitute silently.

A reusable helper to apply this pattern to any run:

# pip install python-docx
from docx.oxml.ns import qn

def set_run_fonts(run, latin: str, cjk: str | None = None) -> None:
    """Set latin font (and optionally CJK font) on a run."""
    run.font.name = latin
    if cjk:
        rpr    = run._r.get_or_add_rPr()
        rfonts = rpr.get_or_add_rFonts()
        rfonts.set(qn("w:eastAsia"), cjk)

Fix 3 — Define a Named Character Style

Applying direct formatting run-by-run is verbose and fragile at scale. Define a named character style once on the document and apply it by name:

# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from docx.shared import Pt, RGBColor
from docx.enum.style import WD_STYLE_TYPE

OUTPUT = Path("output/word/named_style.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)

doc = Document()

# Create a character style
char_style            = doc.styles.add_style("BrandHighlight", WD_STYLE_TYPE.CHARACTER)
char_style.font.name  = "Trebuchet MS"
char_style.font.size  = Pt(12)
char_style.font.bold  = True
char_style.font.color.rgb = RGBColor(0x1D, 0x4E, 0xD8)   # brand blue

# Add East-Asian coverage to the style via oxml
rpr    = char_style.element.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
rfonts.set(qn("w:eastAsia"), "Noto Sans SC")

# Apply by name to any run
para         = doc.add_paragraph("Total revenue: ")
highlight    = para.add_run("$1.4 M")
highlight.style = doc.styles["BrandHighlight"]   # apply the character style

para.add_run(" — up 12 % year-over-year.")

try:
    doc.save(OUTPUT)
    print(f"Saved: {OUTPUT}")
except OSError as exc:
    print(f"Save failed: {exc}")

Named styles are stored in word/styles.xml and travel with the document. When a user opens the document in Word and modifies the BrandHighlight style definition, every run that references it updates automatically — a key advantage over run-level direct formatting. This is also the approach to prefer when styles are baked into a template file rather than set in code: dynamic mail merge with docxtpl renders Jinja2 placeholders into a pre-styled .docx, so defining the character and paragraph styles once in the template beats re-applying font properties on every generated run.


Fix 4 — Modify the Normal Style and Paragraph Spacing

If every paragraph in the document should use a different font or line spacing baseline, override the Normal paragraph style globally. Because the paragraph, heading, and table building blocks covered in Automating Word Document Creation all inherit from Normal unless they define their own font, changing it once here avoids setting properties on every individual paragraph or run:

# pip install python-docx
from pathlib import Path
from docx import Document
from docx.shared import Pt
from docx.oxml.ns import qn

OUTPUT = Path("output/word/normal_override.docx")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)

doc          = Document()
normal_style = doc.styles["Normal"]

# Change the default font for the whole document
normal_style.font.name = "Source Sans Pro"
normal_style.font.size = Pt(11)

# Apply East-Asian font to the Normal style
rpr    = normal_style.element.get_or_add_rPr()
rfonts = rpr.get_or_add_rFonts()
rfonts.set(qn("w:eastAsia"), "Noto Sans SC")

# Remove default space-after so paragraphs don't balloon with extra whitespace
pf = normal_style.paragraph_format
pf.space_after  = Pt(0)
pf.space_before = Pt(0)
pf.line_spacing = Pt(15)   # 15pt leading for 11pt body text

doc.add_paragraph("This paragraph inherits the new Normal style baseline.")
doc.add_paragraph("So does this one — no per-run overrides needed.")

try:
    doc.save(OUTPUT)
    print(f"Saved: {OUTPUT}")
except OSError as exc:
    print(f"Save failed: {exc}")

Modifying Normal cascades to every paragraph that inherits from it. Custom styles that explicitly define their own font (Heading 1, Body Text, etc.) are unaffected. To change Heading 1's font independently:

h1 = doc.styles["Heading 1"]
h1.font.name = "Montserrat"
h1.font.size = Pt(18)
h1.font.bold = True

Variant — Stripping Direct Formatting From an Existing Document

When a document acquired from an external source has stale run-level font overrides that resist style updates, clear them:

# pip install python-docx
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn

PATH = Path("output/word/existing.docx")

try:
    doc = Document(PATH)
except FileNotFoundError:
    raise SystemExit(f"File not found: {PATH}")

for para in doc.paragraphs:
    for run in para.runs:
        rpr = run._r.find(qn("w:rPr"))
        if rpr is not None:
            # Remove direct font specification — runs will now inherit the style
            for rfonts in rpr.findall(qn("w:rFonts")):
                rpr.remove(rfonts)
            for sz in rpr.findall(qn("w:sz")):
                rpr.remove(sz)

doc.save(PATH)
print("Direct font overrides cleared.")

Use this when a style update at the document level is not flowing through to runs that were formatted individually.


Troubleshooting Table

SymptomRoot causeFix
run.font.name set but Word shows the old fontParagraph or character style sets the same font explicitly and the run's direct-formatting flag was not writtenApply the font on the run and confirm <w:rFonts> appears in the run's <w:rPr> (see Verification)
Latin text styled but CJK characters use a fallback facew:eastAsia is never written by the high-level APISet it via oxml — r_fonts.set(qn("w:eastAsia"), font_name) (Fix 2)
Font renders correctly on your machine, wrong on anotherThe named font is not installed on the opening machine; Word substitutes silentlyEmbed the font in the .docx, or restrict to fonts guaranteed on the target OS
AttributeError on run.font.east_asian_nameNo such property exists in python-docxThere is no high-level setter — reach into rFonts via oxml
Style edit in Word does not update existing runsRuns carry direct formatting that overrides the styleStrip run-level <w:rFonts>/<w:sz> (see the stripping variant above)
color.rgb ignoredA character style or theme color is overriding the run color, or value passed as a string not RGBColorPass RGBColor(r, g, b) and clear any conflicting run.style

Styles against direct formatting Applying a named style stores one reference per paragraph and lets the whole document be restyled by editing the style. Direct run formatting stores font, size and colour on every run, which bloats the file and means a design change requires touching every run. Styles are the maintainable choice for anything generated repeatedly. Where the formatting is stored Named style one reference per paragraph restyle the document by editing one style Direct run formatting font and size on every run a design change touches everything

Verification

Confirm the font attributes are written correctly by re-opening the file and printing the XML:

# pip install python-docx lxml
from pathlib import Path
from docx import Document
from lxml import etree

def check_run_fonts(path: Path) -> None:
    """Print the w:rFonts attributes for every run in the document."""
    doc = Document(path)
    W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"

    for i, para in enumerate(doc.paragraphs):
        for j, run in enumerate(para.runs):
            rpr = run._r.find(f"{{{W}}}rPr")
            if rpr is None:
                continue
            rf = rpr.find(f"{{{W}}}rFonts")
            if rf is not None:
                attrs = {k.split("}")[-1]: v for k, v in rf.attrib.items()}
                print(f"Para {i}, Run {j}: {attrs}")

check_run_fonts(Path("output/word/cjk_font.docx"))

For Fix 2 the output should be:

Para 0, Run 0: {'ascii': 'Arial', 'hAnsi': 'Arial', 'eastAsia': 'Noto Sans SC'}

If eastAsia is missing, the r_fonts.set(qn("w:eastAsia"), ...) line did not execute. Add a print(etree.tostring(r_fonts, ...)) immediately after it to confirm the attribute was written before doc.save() is called.


What happens when the font is missing Word looks for the requested font on the reader's machine, then in the document's embedded fonts if any were embedded, and finally substitutes a metrically similar font. Substitution changes line lengths, which can move a table onto a second page, so a document whose pagination matters should embed its fonts or use ones the audience certainly has. Font resolution on the reader's machine 1. installed locally renders exactly as intended 2. embedded in the file only if embedding was enabled 3. substituted line lengths change, pages move

Part of Automating Word Document Creation.