Add Text Boxes and Shapes to Excel with XlsxWriter
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
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:
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.
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:
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.
Step 3 — Link a box to a cell, and a caveat
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:
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:
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.
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:
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.
Step 5 — Links, positioning and accessibility
A few more options control how the box behaves:
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:
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.
Common pitfalls
- Expecting
textlinkto 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_positionto 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.
Related
- Up one level: Building Excel Reports with XlsxWriter — formatted reports and charts.
- Write Rich Text in a Cell with XlsxWriter — mixed formatting inside a cell.
- Write a Formatted Excel Report with XlsxWriter — the report a note sits on.
- Label Chart Points from Cell Values with XlsxWriter — text on a chart instead of the sheet.
More on report layouts & templates
- GuideBuilding Finance and Operations Reports in Excel with Python
- How-toAllocate Shared Costs Across Departments with Python
- How-toBuild a Bank Reconciliation Report with Python and Excel
- How-toBuild a Bill of Materials Cost Rollup
- How-toBuild a Break-Even Analysis in Excel with Python
- ComparisonBuild a Budget vs Actual Variance Report in Excel with Python