Add Bullet and Numbered Lists with python-docx

Adding lists looks like a solved problem until the output opens in Word:

doc.add_paragraph("First point", style="List Bullet")
doc.add_paragraph("Second point", style="List Bullet")
doc.add_paragraph("Step one", style="List Number")

Depending on the template, this produces KeyError: no style with name 'List Number', bullets with no indentation, or — most confusingly — a second numbered list further down the document that starts at 4 because it continues the first one.

Root Cause

Word does not store "this paragraph is a bullet". It stores a paragraph style plus, separately, a numbering definition: a numbering instance id (numId) and an indentation level (ilvl) in the paragraph's properties. The built-in styles List Bullet and List Number reference numbering definitions that exist in the template's numbering.xml — and a template created from a blank document, or one built by another tool, may not contain them, which is why the style lookup fails. When the styles do exist, every paragraph using List Number shares one numbering instance, so a second list continues the first's counter rather than restarting. Indentation comes from the style's level definitions; a style present without its numbering definition renders as plain text with no bullet at all.

Minimal Diagnostic

Report which list styles the template defines, and which numbering instances the document already uses.

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

TEMPLATE = Path("in/template.docx")

def list_report(path: Path | None = None) -> None:
    doc = Document(path) if path else Document()
    wanted = ["List Bullet", "List Bullet 2", "List Number", "List Number 2", "List Paragraph"]
    available = {s.name for s in doc.styles}
    for name in wanted:
        print(f"  style {name!r}: {'present' if name in available else 'MISSING'}")
    numbering_part = None
    for rel in doc.part.rels.values():
        if "numbering" in rel.reltype:
            numbering_part = rel.target_part
    if numbering_part is None:
        print("  numbering.xml: absent — no list definitions in this document")
    else:
        nums = numbering_part.element.findall(qn("w:num"))
        abstract = numbering_part.element.findall(qn("w:abstractNum"))
        print(f"  numbering.xml: {len(nums)} numbering instance(s), {len(abstract)} definition(s)")
    used = Counter()
    for paragraph in doc.paragraphs:
        num_pr = paragraph._p.find(qn("w:pPr"))
        num_pr = num_pr.find(qn("w:numPr")) if num_pr is not None else None
        if num_pr is None:
            continue
        num_id = num_pr.find(qn("w:numId"))
        ilvl = num_pr.find(qn("w:ilvl"))
        used[(num_id.get(qn("w:val")) if num_id is not None else "?",
              ilvl.get(qn("w:val")) if ilvl is not None else "0")] += 1
    print(f"  paragraphs with numbering (numId, level): {dict(used) or 'none'}")

if __name__ == "__main__":
    print("blank document:")
    list_report(None)
    print(f"{TEMPLATE.name}:")
    list_report(TEMPLATE)
blank document:
  style 'List Bullet': present
  style 'List Number': present
  numbering.xml: absent — no list definitions in this document
  paragraphs with numbering (numId, level): none
template.docx:
  style 'List Bullet': present
  style 'List Number': MISSING
  numbering.xml: 3 numbering instance(s), 2 definition(s)
  paragraphs with numbering (numId, level): {('2', '0'): 14}

The default template defines the styles but has no numbering part until a list is actually created; the customer's template has bullets but no numbered-list style at all.

What makes a paragraph a list item A list paragraph carries a style such as List Bullet, which supplies font and spacing. Its paragraph properties carry a numPr element with a numId identifying a numbering instance and an ilvl giving the indentation level. The numbering instance points at an abstract numbering definition in numbering.xml, which defines the bullet character or number format and the indentation for each level. Remove any layer and the paragraph renders as plain text. Paragraph style, e.g. List Bullet font, spacing, alignment may exist without numbering numPr: numId + ilvl which list, which level what makes the bullet appear Numbering instance links to a definition shared by paragraphs in one list Abstract definition bullet char or number format per level lives in numbering.xml

Fix: Create a Numbering Definition per List

Add an abstract definition and a fresh numbering instance for each list, then attach it to the paragraphs. Every list gets its own instance, so numbering restarts naturally. Changed lines carry comments.

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

BULLETS = ["•", "◦", "▪"]                  # bullet glyphs per level

def numbering_part(doc: Document):
    for rel in doc.part.rels.values():
        if "numbering" in rel.reltype:
            return rel.target_part
    from docx.parts.numbering import NumberingPart          # changed: create the part if absent
    part = NumberingPart.new()
    doc.part.relate_to(part, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering")
    return part

def define_list(doc: Document, numbered: bool, levels: int = 3, start_at: int = 1) -> int:
    """Create a fresh numbering instance; returns its numId."""
    part = numbering_part(doc)
    root = part.element
    abstract_ids = [int(a.get(qn("w:abstractNumId"))) for a in root.findall(qn("w:abstractNum"))]
    num_ids = [int(n.get(qn("w:numId"))) for n in root.findall(qn("w:num"))]
    abstract_id = max(abstract_ids, default=-1) + 1
    num_id = max(num_ids, default=0) + 1                    # changed: unique per list

    abstract = OxmlElement("w:abstractNum")
    abstract.set(qn("w:abstractNumId"), str(abstract_id))
    for level in range(levels):
        lvl = OxmlElement("w:lvl")
        lvl.set(qn("w:ilvl"), str(level))
        start = OxmlElement("w:start")
        start.set(qn("w:val"), str(start_at if level == 0 else 1))
        fmt = OxmlElement("w:numFmt")
        fmt.set(qn("w:val"), "decimal" if numbered else "bullet")
        text = OxmlElement("w:lvlText")
        text.set(qn("w:val"), f"%{level + 1}." if numbered else BULLETS[level % len(BULLETS)])
        justify = OxmlElement("w:lvlJc")
        justify.set(qn("w:val"), "left")
        ppr = OxmlElement("w:pPr")
        indent = OxmlElement("w:ind")
        indent.set(qn("w:left"), str(int(Cm(0.75 * (level + 1)).twips)))    # changed: indentation per level
        indent.set(qn("w:hanging"), str(int(Cm(0.5).twips)))
        ppr.append(indent)
        for element in (start, fmt, text, justify, ppr):
            lvl.append(element)
        abstract.append(lvl)
    root.insert(0, abstract)

    num = OxmlElement("w:num")
    num.set(qn("w:numId"), str(num_id))
    link = OxmlElement("w:abstractNumId")
    link.set(qn("w:val"), str(abstract_id))
    num.append(link)
    root.append(num)
    return num_id

def add_list_item(doc: Document, text: str, num_id: int, level: int = 0, style: str = "List Paragraph"):
    paragraph = doc.add_paragraph(text, style=style if style in {s.name for s in doc.styles} else None)
    ppr = paragraph._p.get_or_add_pPr()
    num_pr = OxmlElement("w:numPr")
    ilvl = OxmlElement("w:ilvl")
    ilvl.set(qn("w:val"), str(level))
    num = OxmlElement("w:numId")
    num.set(qn("w:val"), str(num_id))
    num_pr.append(ilvl)
    num_pr.append(num)
    ppr.append(num_pr)                                       # changed: attach numbering to the paragraph
    return paragraph

if __name__ == "__main__":
    doc = Document()
    doc.add_heading("Checklist", level=1)
    bullets = define_list(doc, numbered=False)
    for text, level in [("Collect documents", 0), ("Passport", 1), ("Proof of address", 1), ("Submit", 0)]:
        add_list_item(doc, text, bullets, level)
    doc.add_heading("Procedure", level=1)
    steps = define_list(doc, numbered=True)                   # changed: a separate instance, starts at 1
    for text in ("Open the case", "Verify the documents", "Approve or reject"):
        add_list_item(doc, text, steps)
    doc.add_heading("Second procedure", level=1)
    more = define_list(doc, numbered=True)                    # changed: restarts at 1, not 4
    for text in ("Record the outcome", "Notify the customer"):
        add_list_item(doc, text, more)
    Path("out").mkdir(exist_ok=True)
    doc.save("out/lists.docx")

Calling define_list per list is what makes numbering restart: two numbering instances pointing at the same abstract definition each keep their own counter. Using List Paragraph for the style — Word's own style for list items — keeps spacing consistent, and the code falls back to no style when a template lacks it rather than raising KeyError.

One numbering instance versus one per list With both lists using the built-in List Number style, they share one numbering instance, so the second list continues at four, five and six. With a fresh numbering instance defined for each list, the first list runs one to three and the second restarts at one and two, which is what readers expect after a new heading. Shared numId Procedure 1. Open the case 2. Verify documents Second procedure 4. Record outcome One numId per list Procedure 1. Open the case 2. Verify documents Second procedure 1. Record outcome

Variant Fix 1: Template Styles Exist — Reuse Them

When a corporate template already defines list styles with the right fonts and indentation, use them and only override the numbering instance so lists restart:

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

def style_exists(doc: Document, name: str) -> bool:
    return name in {s.name for s in doc.styles}

def add_styled_list(doc: Document, items: list[tuple[str, int]], numbered: bool) -> None:
    base = "List Number" if numbered else "List Bullet"
    num_id = define_list(doc, numbered=numbered)              # fresh counter even with a template style
    for text, level in items:
        style = base if level == 0 else f"{base} {level + 1}"
        paragraph = doc.add_paragraph(text, style=style if style_exists(doc, style) else
                                      (base if style_exists(doc, base) else None))
        ppr = paragraph._p.get_or_add_pPr()
        existing = ppr.find(qn("w:numPr"))
        if existing is not None:
            ppr.remove(existing)                              # drop the style's numbering
        add_numbering(ppr, num_id, level)                     # attach ours instead

Word's convention is List Bullet 2, List Bullet 3 for deeper levels, and templates often define only the first. Falling back to the base style keeps the document valid; the indentation then comes from the numbering definition rather than the style, which is why define_list sets it explicitly.

Variant Fix 2: Lists Inside Table Cells and Templates

List paragraphs work the same way inside table cells, but doc.add_paragraph adds to the body — a cell needs its own paragraph:

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

def add_numbering(ppr, num_id: int, level: int = 0) -> None:
    num_pr = OxmlElement("w:numPr")
    ilvl = OxmlElement("w:ilvl")
    ilvl.set(qn("w:val"), str(level))
    num = OxmlElement("w:numId")
    num.set(qn("w:val"), str(num_id))
    num_pr.append(ilvl)
    num_pr.append(num)
    ppr.append(num_pr)

def bullet_list_in_cell(cell, items: list[str], num_id: int) -> None:
    if cell.paragraphs and not cell.paragraphs[0].text:
        cell.paragraphs[0].text = items[0]                    # reuse the empty paragraph a cell starts with
        first, rest = cell.paragraphs[0], items[1:]
    else:
        first, rest = cell.add_paragraph(items[0]), items[1:]
    for paragraph, text in [(first, None)] + [(cell.add_paragraph(t), None) for t in rest]:
        add_numbering(paragraph._p.get_or_add_pPr(), num_id, 0)

Every cell starts with one empty paragraph; adding another without using it leaves a blank line above the list, which is the usual reason a table row looks too tall. The same applies to docxtpl templates, where a loop that emits list items must reuse the tagged paragraph — the mechanics are in loop table rows in docxtpl templates.

Choosing how to create a list The root asks what the template provides. If it defines list styles and only one list appears in the document, the styles alone are enough. If it defines styles but the document has several lists, keep the styles for appearance and attach a fresh numbering instance per list so counters restart. If the template lacks list styles, define both the numbering and the indentation in code. If bullets must match a brand, define the glyph in the abstract numbering definition. What does the template provide? check styles and numbering.xml styles, one list Use styles nothing else needed styles, many lists Styles + own numId counters restart no list styles Define numbering glyphs and indents in code brand bullets Custom lvlText any glyph per level

Continuing a List Deliberately

Sometimes continuation is wanted — a numbered procedure interrupted by a note paragraph. Reuse the same numId for the items that belong together:

# pip install python-docx
from docx import Document

def procedure_with_note(doc: Document, before: list[str], note: str, after: list[str]) -> None:
    num_id = define_list(doc, numbered=True)
    for text in before:
        add_list_item(doc, text, num_id)
    paragraph = doc.add_paragraph(note)                       # plain paragraph, no numbering
    paragraph.paragraph_format.left_indent = doc.styles["Normal"].paragraph_format.left_indent
    for text in after:
        add_list_item(doc, text, num_id)                      # same numId: numbering continues

Because the counter belongs to the numbering instance rather than to adjacent paragraphs, the interruption does not reset it — which is exactly the behaviour that surprises people when it happens by accident across two unrelated lists, and exactly what is needed here.

Verification

Open the saved document and assert the numbering structure, not just the text.

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

def verify_lists(path: Path, expected_lists: int, expected_items: int) -> None:
    doc = Document(path)
    per_list = defaultdict(list)
    for paragraph in doc.paragraphs:
        ppr = paragraph._p.find(qn("w:pPr"))
        num_pr = ppr.find(qn("w:numPr")) if ppr is not None else None
        if num_pr is None:
            continue
        num_id = num_pr.find(qn("w:numId")).get(qn("w:val"))
        level = num_pr.find(qn("w:ilvl")).get(qn("w:val"))
        per_list[num_id].append((int(level), paragraph.text))
    assert len(per_list) == expected_lists, f"{len(per_list)} numbering instance(s), expected {expected_lists}"
    total = sum(len(items) for items in per_list.values())
    assert total == expected_items, f"{total} list item(s), expected {expected_items}"
    for num_id, items in per_list.items():
        assert items[0][0] == 0, f"list {num_id} starts at level {items[0][0]}, not 0"
        levels = [level for level, _ in items]
        assert all(b - a <= 1 for a, b in zip(levels, levels[1:])), f"list {num_id} skips a level"
    print(f"{path.name}: {len(per_list)} lists, {total} items, levels well-formed")

if __name__ == "__main__":
    verify_lists(Path("out/lists.docx"), expected_lists=3, expected_items=9)

Checking that levels never jump by more than one catches a nesting bug that looks fine in code and renders as a sub-item with no parent in Word. Counting numbering instances is what proves each list will restart: one instance for three lists means the numbering continues, however the text reads.

FAQ

Why do my bullets have no indentation? The numbering definition supplies indentation. A style without its definition renders flush left; define w:ind per level as the fix does.

Can I use a custom bullet character? Yes — set w:lvlText to any character, and set the level's font to Symbol or Wingdings if the glyph needs it.

How do I make a lettered list (a, b, c)? Set w:numFmt to lowerLetter (or upperRoman, lowerRoman, upperLetter) in the level definition.

Do these lists survive conversion to PDF? Yes — numbering is part of the document, so LibreOffice and Word both render it, as covered in Converting DOCX to PDF with Python.

Part of Automating Word Document Creation.