Guide
Formatting And Charting Excel Reports With PythonDeep dive

Add Comments and Notes to Excel Cells with Python

Annotate generated Excel reports from Python: attach openpyxl comments, size them, read existing notes back, and use document properties to record where the numbers came from.

A number in a report always raises the same question eventually: where did this come from, and why is it that? Answering it in a covering email works until the file is forwarded. Answering it in the cell itself travels with the data. openpyxl attaches comments in two lines, and used well they carry the provenance of a figure — the source system, the as-at date, the assumption behind a forecast — exactly where a reader is looking when the question occurs to them. This guide covers writing, sizing, bulk annotation and reading comments back. It completes Protecting and Sharing Excel Workbooks.

What an attached note looks like in the sheet A small grid fragment showing a Unit price column. The cell holding 12.50 carries a marker in its corner, and a connected callout box displays the note text explaining that the price comes from the Q3 price list effective the first of July, together with the author name Reporting. The note travels inside the file rather than in a covering email. Unit price 12.50 11.00 13.25 Reporting: Unit price from the Q3 price list, effective 1 July. Update the Lookups sheet, not this cell. it travels with the file a covering email does not the marker in the corner is the only thing the reader sees until they hover

Prerequisites

Bash
pip install openpyxl pandas

A distinction worth having straight before you start. Excel now has two annotation mechanisms: notes (the classic yellow hover boxes) and threaded comments (the newer reply-based ones). openpyxl writes notes, and Excel displays them under its Notes menu. That is the right choice for generated annotations — a script is making a statement, not opening a conversation — but it does explain why the annotations you write do not appear where a colleague expects to find "comments".

Step 1 — Attach a comment

The Comment object takes text and an author, and you assign it to a cell:

Python
from openpyxl import Workbook
from openpyxl.comments import Comment

wb = Workbook()
ws = wb.active
ws.title = "Forecast"

ws["A1"] = "Region"
ws["B1"] = "Unit price"
ws["A2"], ws["B2"] = "North", 12.50

note = Comment(
    "Unit price from the Q3 price list, effective 1 July.\n"
    "Update the Lookups sheet, not this cell.",
    "Reporting",
)
ws["B2"].comment = note

wb.save("forecast.xlsx")

The default box is small and clips anything beyond a short sentence. Size it to the content — the units are pixels:

Python
note = Comment("A longer explanation that would otherwise be cut off "
               "by the default comment box size.", "Reporting")
note.width = 280
note.height = 110
ws["B2"].comment = note

A rough rule that works well: allow about 7 pixels of width per character on the longest line, and about 18 pixels of height per line of text plus a little for the author name.

One constraint to know: a Comment object can be assigned to one cell only. Reusing the same instance across cells silently moves it rather than copying it, so the annotation ends up on the last cell alone. Build a fresh one each time:

Python
from openpyxl.comments import Comment

# Wrong — one object, one cell. B2 and B3 will not both end up annotated.
shared = Comment("Provisional", "Reporting")
ws["B2"].comment = shared
ws["B3"].comment = shared        # B2's note is now gone

# Right — a new object per cell.
for ref in ("B2", "B3"):
    ws[ref].comment = Comment("Provisional", "Reporting")

Step 2 — Annotate in bulk from a rules table

Hand-placing comments does not scale past a few. Drive them from a table of rules, so the annotation logic sits in one readable place:

Python
from openpyxl import load_workbook
from openpyxl.comments import Comment

AUTHOR = "Reporting"

# (header name, predicate, message)
RULES = [
    ("revenue", lambda v: isinstance(v, (int, float)) and v == 0,
     "Zero revenue — check the source extract ran for this region."),
    ("revenue", lambda v: isinstance(v, (int, float)) and v > 100_000,
     "Above the usual range. Confirm before circulating."),
    ("status", lambda v: v == "provisional",
     "Provisional figure. Restated at month end."),
]

def annotate(path, dest, sheet="Summary"):
    wb = load_workbook(path)
    ws = wb[sheet]

    headers = {str(c.value).strip(): c.column for c in ws[1] if c.value}
    added = 0

    for name, predicate, message in RULES:
        col = headers.get(name)
        if col is None:
            continue
        for (cell,) in ws.iter_rows(min_row=2, min_col=col, max_col=col):
            if cell.comment is None and predicate(cell.value):
                note = Comment(message, AUTHOR)
                note.width, note.height = 260, 80
                cell.comment = note
                added += 1

    wb.save(dest)
    return added

print(f"added {annotate('report.xlsx', 'report_annotated.xlsx')} notes")

The cell.comment is None guard makes the function idempotent — running it twice does not stack duplicate notes, which matters when a job retries. This pairs naturally with the visual flagging in highlighting invalid cells in Excel with Python: the fill catches the eye, the comment explains why.

Step 3 — Read comments back

Comments are readable, which makes them useful as a lightweight audit trail — you can extract every annotation from an incoming workbook and act on it:

Turning scattered cell notes into one reviewable table Three annotated cells scattered across two sheets on the left feed an extraction step that walks every cell and collects the ones whose comment attribute is not None. The result on the right is a flat table with one row per note, carrying the sheet name, the cell coordinate, the cell's value, the author and the note text — a form that can be sorted, filtered and circulated. notes scattered in cells Summary!C4 Summary!C9 Detail!F22 walk every cell keep comment is not None one flat table of notes sheet · cell · value author · text sortable, filterable, circulatable
Python
import pandas as pd
from openpyxl import load_workbook

def extract_comments(path):
    """Collect every cell note in a workbook into a DataFrame."""
    wb = load_workbook(path)
    rows = []
    for ws in wb.worksheets:
        for row in ws.iter_rows():
            for cell in row:
                if cell.comment is not None:
                    rows.append({
                        "sheet": ws.title,
                        "cell": cell.coordinate,
                        "value": cell.value,
                        "author": cell.comment.author,
                        "note": cell.comment.text,
                    })
    return pd.DataFrame(rows)

notes = extract_comments("report_annotated.xlsx")
print(notes)
notes.to_excel("notes_log.xlsx", index=False)

Removing them is the same walk with an assignment. Useful when a workbook carrying internal working notes is about to be sent outside:

Python
from openpyxl import load_workbook

def strip_comments(path, dest):
    wb = load_workbook(path)
    removed = 0
    for ws in wb.worksheets:
        for row in ws.iter_rows():
            for cell in row:
                if cell.comment is not None:
                    cell.comment = None
                    removed += 1
    wb.save(dest)
    return removed

print(f"removed {strip_comments('internal.xlsx', 'external.xlsx')} notes")

That is a genuinely important step before external distribution. Working notes written for colleagues read very differently to a customer, and nothing in the file warns you they are there.

Step 4 — Record provenance at the file level

Some context belongs to the whole workbook rather than a cell. Document properties carry it, and they survive forwarding in a way a covering email does not:

Python
from datetime import datetime
from openpyxl import load_workbook

wb = load_workbook("report.xlsx")
props = wb.properties
props.title = "Regional revenue forecast"
props.subject = "Monthly reporting pack"
props.creator = "Reporting automation"
props.description = (
    "Generated from the sales warehouse, snapshot 2026-08-15 06:00 UTC. "
    "Provisional until month-end close."
)
props.keywords = "revenue; forecast; monthly"
props.created = datetime(2026, 8, 15, 6, 0)
props.modified = datetime(2026, 8, 15, 6, 0)
wb.save("report.xlsx")

A visible header row on the sheet itself does the same job for readers who never open the properties dialog — which is most of them:

Python
from openpyxl import load_workbook
from openpyxl.styles import Font

wb = load_workbook("report.xlsx")
ws = wb["Summary"]
ws.insert_rows(1)
ws["A1"] = ("Generated 2026-08-15 06:00 UTC from the sales warehouse "
            "· provisional until month-end close")
ws["A1"].font = Font(italic=True, color="5B6780", size=9)
ws.merge_cells("A1:F1")
wb.save("report.xlsx")

Common pitfalls and fixes

SymptomCauseFix
Only the last cell has a noteOne Comment object reusedConstruct a new Comment per cell.
Comment text is cut offDefault box too smallSet width and height on the object.
Comments gone after the job runsto_excel replaced the sheetAnnotate as the final step.
Notes not under Excel's Comments menuopenpyxl writes classic notesLook under Notes; this is expected.
Duplicate notes after a retryNo idempotency guardSkip cells where cell.comment is not None.
Comments not in the printed outputExcel does not print notes by defaultSet the sheet's print options, or use a visible column.
Internal notes reached a clientNever stripped before sendingRun a strip_comments step before external delivery.

Performance and scale notes

Comment or notes column, decided by how many rows need one Two panels. A comment is one drawing object per annotated cell, is invisible until hovered, and cannot be filtered, sorted or exported — which suits a handful of exceptional cells. A notes column costs nothing per row, is visible at a glance, and filters, sorts and exports like any other column — which suits the case where most rows have something to say. The crossover in practice is a few hundred annotations. a cell comment one drawing object per note invisible until hovered cannot be filtered or sorted never reaches a CSV export right for a handful of exceptions a notes column no per-row object at all visible at a glance filters and sorts like any column survives every export right when most rows have something to say

Every comment is a separate drawing object in the file, with its own XML and anchor. The cost is small individually and adds up quickly: a few hundred notes is unremarkable, several thousand noticeably inflates the file and slows Excel's rendering, and tens of thousands makes a workbook uncomfortable to open.

The scaling rule is therefore about choosing the right mechanism, not optimising the wrong one:

SituationUse
A handful of exceptional cells need explainingComments
Every row has something to sayA real notes column
A whole column shares one caveatA header note or a comment on the header cell
The caveat applies to the fileDocument properties plus a header row

A notes column is not a fallback — it is better in most respects. It can be filtered, sorted, exported to CSV, and read by pandas, none of which a comment can:

Python
import numpy as np
import pandas as pd

df["note"] = np.select(
    [df["revenue"] == 0, df["revenue"] > 100_000],
    ["zero — check the extract", "above the usual range"],
    default="",
)
df.to_excel("report.xlsx", index=False)

That is vectorised and costs nothing regardless of row count, where annotating the same rows with comments would create one drawing object each.

Two more notes for large jobs. Comments cannot be written in openpyxl's write_only mode, so a streaming write followed by an annotation pass means re-opening the file in normal mode — which defeats the memory saving. And the cell walk in extract_comments above visits every cell in the used range; on a large workbook, restrict it to the sheets and ranges you care about rather than scanning the lot.

Conclusion

Comments put the answer to "why is this number what it is" next to the number itself, where the question actually gets asked. Build a fresh Comment per cell, size the box to the text, and guard on cell.comment is None so retries do not stack duplicates. Read them back when you need an audit trail, and strip them before a workbook goes outside the organisation. When every row needs an explanation, use a real column instead — it filters, sorts and exports, and it costs nothing at any scale.

Frequently asked questions

Do openpyxl comments show up as modern threaded comments in Excel? No. openpyxl writes classic notes — the yellow hover boxes attached to a cell. Excel shows them under Notes rather than Comments. They are the right tool for machine-generated annotations, which are statements rather than conversations.

Why did my comments disappear after the script ran again? Something rewrote the sheet. pandas to_excel replaces a sheet wholesale, discarding comments along with protection and column widths. Annotate as the final step, or edit cells through openpyxl rather than replacing the sheet.

Can I control the size of a comment box? Yes — set width and height on the Comment object in pixels before assigning it. The default box is small and clips longer text, so size it to the content.

How do I read comments that are already in a workbook? Iterate the cells and check cell.comment, which is None where there is no note. Its text and author attributes hold the content, so you can audit or migrate annotations.

Should I use comments or a notes column? Comments for occasional explanations that would clutter the grid; a real column when every row has something to say, because a column can be filtered, sorted and exported while a comment cannot.