How-to
Formatting And Charting Excel Reports With PythonDeep dive

Add Text Boxes and Shapes to Excel with XlsxWriter

Float commentary, notes and labels over an Excel report with XlsxWriter insert_textbox: size, fill, border and font options, multi-line and rotated text, gradient fills and hyperlinks, text linked to a cell (and why it shows empty in LibreOffice), placing a box below a table of any length, and when a formatted cell is the better choice.

A generated report often needs a sentence of commentary that does not belong in any cell: "Revenue up 12% on last month — driven by the North region", a "figures unaudited" warning, or a note pointing readers at the method. Typing it into a cell makes it fight with column widths and get sorted along with the data. A text box floats over the sheet instead, sized and styled independently of the grid. XlsxWriter adds them with insert_textbox(), and with a handful of options a box can carry fills, borders, fonts, rotated text and a hyperlink. This page goes through those options, the cell-linked text feature and its LibreOffice caveat, and how to place a box beneath a table whose length changes each run. It belongs to Building Excel Reports with XlsxWriter.

Prerequisites

Bash
pip install xlsxwriter pandas pypdf

The examples write new workbooks, which is what XlsxWriter does — it cannot edit an existing file. LibreOffice (soffice) is used in Step 6 to render the result and check which text appears; it is optional.

Step 1 — A first text box

insert_textbox(cell, text, options) anchors the box's top-left corner at a cell and draws it on top of the grid:

Python
import xlsxwriter

book = xlsxwriter.Workbook("textbox_basic.xlsx")
sheet = book.add_worksheet("Report")
sheet.write_column("A1", ["Month", "Jan", "Feb", "Mar"])
sheet.write_column("B1", ["Revenue", 41200, 44100, 49400])
sheet.insert_textbox("D2", "Figures are unaudited and may change.", {
    "width": 260, "height": 60,
    "font": {"bold": True, "color": "#4338CA", "size": 12},
    "fill": {"color": "#EEF2FF"},
    "line": {"color": "#4338CA", "width": 1.25},
    "align": {"vertical": "middle", "horizontal": "center"},
})
book.close()
print("text box anchored at D2, 260 x 60 px")

width and height are in pixels (the default box is 192 × 120). fill and line take the same colour and width settings as chart elements in XlsxWriter, font takes name, size, bold, italic, underline and colour, and align places the text vertically (top, middle, bottom) and horizontally (left, center, right). The box is not part of any cell, so sorting, filtering or resizing columns leaves its text alone.

What a text box option dict controls The insert_textbox options split into four groups. Geometry sets the anchor cell, width, height and offsets. Appearance sets fill, line and gradient. Text sets font, alignment and rotation. Behaviour sets the hyperlink, object positioning and description for screen readers. Geometry anchor cell, size, offsets Appearance fill, line, gradient Text font, align, rotation Behaviour link, positioning, alt text

Step 2 — Multi-line, rotated and borderless text

A newline in the text starts a new line in the box. text_rotation turns the text, which suits a vertical "DRAFT" strip down the side of a page, and "line": {"none": True} removes the border so the box reads as a shaded note:

Python
book = xlsxwriter.Workbook("textbox_styles.xlsx")
sheet = book.add_worksheet("Report")
sheet.insert_textbox("B2", "Method\n• Totals exclude returns\n• Prices in EUR", {
    "width": 220, "height": 80,
    "line": {"none": True},
    "fill": {"color": "#E0E7FF"},
})
sheet.insert_textbox("F2", "DRAFT", {
    "width": 40, "height": 160,
    "text_rotation": 90,
    "font": {"bold": True, "size": 16, "color": "#B91C1C"},
})
sheet.insert_textbox("B9", "Quarter highlights", {
    "width": 220, "height": 40,
    "gradient": {"colors": ["#DDEBCF", "#9CB86E"]},
    "font": {"bold": True},
})
book.close()
print("three styled boxes")

text_rotation accepts 0, 90, −90, 270 and 271 (stacked letters). A gradient takes two or more colours and replaces the solid fill. In a LibreOffice rendering, all three boxes appeared as set: the bullet list on three lines, the rotated red DRAFT strip and the green gradient.

Instead of fixed text, a box can display the value of a cell with textlink. When the cell changes — say, a formula that builds a commentary sentence — the box follows:

Python
book = xlsxwriter.Workbook("textbox_linked.xlsx")
sheet = book.add_worksheet("Report")
sheet.write_column("A1", ["Month", "Jan", "Feb", "Mar"])
sheet.write_column("B1", ["Revenue", 41200, 44100, 49400])
sheet.write_formula("D1", '="Revenue up "&TEXT(B4/B3-1,"0%")&" on last month"', None, "Revenue up 12% on last month")
sheet.insert_textbox("D3", "", {"textlink": "=Report!$D$1", "width": 260, "height": 40, "fill": {"color": "#FEF3C7"}})
book.close()
print("box linked to Report!$D$1")

Excel resolves the link when it opens the file and shows "Revenue up 12% on last month" in the box. But XlsxWriter stores the link with an empty cached text, and LibreOffice (tested with version 26.2) shows the box empty — it does not resolve the link. If your readers use LibreOffice, or the workbook is converted to PDF on a server, write the text into the box yourself. A report generator usually knows the numbers anyway, so composing the sentence in Python is no extra work:

Python
growth = 49400 / 44100 - 1
book = xlsxwriter.Workbook("textbox_computed.xlsx")
sheet = book.add_worksheet("Report")
sheet.insert_textbox("D3", f"Revenue up {growth:.0%} on last month", {"width": 260, "height": 40, "fill": {"color": "#FEF3C7"}})
book.close()
print(f"Revenue up {growth:.0%} on last month")

Use textlink when people will edit the workbook in Excel and expect the commentary to follow their changes; use computed text when the file is a finished report.

Fixed text or a cell link? If the workbook is a finished report or may be opened in LibreOffice, compose the text in Python and pass it to the box. If people will edit the source cells in Excel and the commentary should follow, use textlink, accepting that LibreOffice shows the box empty. Will readers edit the cells in Excel? yes, live workbook textlink to the cell Excel resolves it on open no, finished report text composed in Python shows in every application

Step 4 — Place a box below a table of any length

Anchoring a note at a fixed cell breaks when next month's table has more rows. Compute the anchor from the data instead — two rows below the last one written:

Python
import pandas as pd

sales = pd.DataFrame({"Region": ["North", "South", "East", "West", "Central"],
                      "Revenue": [18400, 12100, 9800, 15300, 7200]})

with pd.ExcelWriter("textbox_after_table.xlsx", engine="xlsxwriter") as writer:
    sales.to_excel(writer, sheet_name="Report", index=False)
    sheet = writer.sheets["Report"]
    note_row = len(sales) + 2                       # header + data + one blank row
    top = sales.loc[sales["Revenue"].idxmax(), "Region"]
    sheet.insert_textbox(note_row, 0, f"{top} led with {sales['Revenue'].max():,} in revenue.", {
        "width": 300, "height": 36, "line": {"none": True}, "fill": {"color": "#F1F5F9"},
    })
print("note anchored at row", note_row + 1)

insert_textbox accepts a zero-based (row, col) pair as well as an A1 string, which makes computed positions straightforward. With five data rows plus a header, the note lands at row 8, one blank row below the table; with fifty rows it moves down with them. x_offset and y_offset nudge the box within its anchor cell in pixels. Choosing between the writer engines is covered in openpyxl vs XlsxWriter vs pandas ExcelWriter.

A few more options control how the box behaves:

Python
book = xlsxwriter.Workbook("textbox_behaviour.xlsx")
sheet = book.add_worksheet("Report")
sheet.insert_textbox("B2", "Read the methodology", {
    "url": "https://example.com/method",
    "tip": "Opens the methodology page",
    "object_position": 1,
    "description": "Link to the report methodology",
    "width": 200, "height": 30,
})
book.close()
print("linked box written")

url makes the whole box clickable (and tip sets the hover text). object_position decides what happens when rows and columns around the box are resized: 1 (the default) moves and sizes it with the cells, 2 moves it without resizing, 3 leaves it fixed in place, and 4 behaves like 1 but also lets XlsxWriter account for rows and columns hidden after the box is inserted. description is the alternative text screen readers announce — part of making a report usable for everyone, as in Make an Excel Report Accessible.

What about other shapes?

XlsxWriter's text boxes are rectangles; it has no API for arrows, ovals or callouts. For a highlighted callout next to a figure, a text box with a coloured fill and no border usually does the job. For a pointer to a specific value, conditional formatting or a bold, coloured cell is more robust, because it stays attached to the value when rows are sorted. openpyxl does not add shapes either, and it drops existing shapes and text boxes when it saves a workbook — see What openpyxl Loses When Saving an Existing Workbook. For an image-based overlay such as a logo or a "Confidential" mark, use a picture instead: Add a Watermark to an Excel Sheet.

Step 6 — Check the text in the rendered output

Floating objects are easy to get wrong without seeing them. Render the workbook and check that each box's text is present:

Python
import subprocess
from pypdf import PdfReader

def page_text(xlsx):
    subprocess.run(["soffice", "--headless", "--convert-to", "pdf", xlsx], check=True, capture_output=True)
    return PdfReader(xlsx.replace(".xlsx", ".pdf")).pages[0].extract_text()

sentence = "Revenue up 12% on last month"
for f in ("textbox_linked.xlsx", "textbox_computed.xlsx", "textbox_after_table.xlsx"):
    text = page_text(f)
    print(f"{f:26} sentence x{text.count(sentence)}  note: {'North led' in text}")

In the linked file, the sentence appears only once — from cell D1 — and the box itself is empty in LibreOffice. In the computed file the sentence comes from the box, and in the table file the "North led with 18,400 in revenue." note appears below the five regions. The same check in a test catches a box whose text went missing.

A note placed two rows below the table A sheet with a header row and five region rows. Row 7 is blank. From row 8 a shaded text box spans columns A to C with the sentence North led with 18,400 in revenue. A B C 1 Region Revenue 2 North 18,400 3 South 12,100 4 East 9,800 5 West 15,300 6 Central 7,200 7 8 text box: North led with 18,400

Common pitfalls

  • Expecting textlink to show in LibreOffice. The box stays empty there; pass the text instead.
  • A fixed anchor below a growing table. Compute the row from the data length.
  • Commentary in a cell. It gets sorted, filtered and squashed by column widths; float it instead.
  • Trying to edit an existing file with XlsxWriter. It only writes new workbooks.
  • Boxes that stretch when rows resize. Set object_position to 2 or 3 to keep the box its own size.

Conclusion

insert_textbox() puts free-floating commentary on an XlsxWriter sheet, with pixel sizes, fills, borders, gradients, fonts, alignment, rotation and hyperlinks controlled by one option dict. Text linked to a cell with textlink follows the cell in Excel but appears empty in LibreOffice, so finished reports should compose the sentence in Python. Anchor notes from the data length so they stay below tables, give boxes a description for screen readers, and render the output to check the text is there.

Frequently asked questions

How do I add a text box to an Excel sheet with Python? With XlsxWriter, call worksheet.insert_textbox with an anchor cell, the text and an options dict for width, height, font, fill, line and alignment. The box floats over the cells.

Can a text box show the value of a cell? Yes, with the textlink option such as =Report!$D$1. Excel resolves the link when it opens the file; LibreOffice showed the box empty, so for portable reports compose the text in Python.

How do I put a note below a table of changing length? Pass a zero-based row and column to insert_textbox and compute the row from the number of data rows, for example the length of the DataFrame plus two.

Can XlsxWriter draw arrows or other shapes? No. Its text boxes are rectangles. Use a filled, borderless text box as a callout, formatting on the cell itself to point at a value, or an image for logos and watermarks.

More on report layouts & templates

More on report layouts & templates →