Add Comments and Notes to Excel Cells with Python
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.
Prerequisites
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:
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:
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:
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:
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:
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:
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:
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:
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
| Symptom | Cause | Fix |
|---|---|---|
| Only the last cell has a note | One Comment object reused | Construct a new Comment per cell. |
| Comment text is cut off | Default box too small | Set width and height on the object. |
| Comments gone after the job runs | to_excel replaced the sheet | Annotate as the final step. |
| Notes not under Excel's Comments menu | openpyxl writes classic notes | Look under Notes; this is expected. |
| Duplicate notes after a retry | No idempotency guard | Skip cells where cell.comment is not None. |
| Comments not in the printed output | Excel does not print notes by default | Set the sheet's print options, or use a visible column. |
| Internal notes reached a client | Never stripped before sending | Run a strip_comments step before external delivery. |
Performance and scale notes
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:
| Situation | Use |
|---|---|
| A handful of exceptional cells need explaining | Comments |
| Every row has something to say | A real notes column |
| A whole column shares one caveat | A header note or a comment on the header cell |
| The caveat applies to the file | Document 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:
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.
Related
- Up to the parent: Protecting and Sharing Excel Workbooks — comments alongside the other sharing controls.
- Highlight Invalid Cells in Excel with Python — the visual flag a comment explains.
- Lock Cells and Protect a Sheet with openpyxl — stopping the edit the comment warns against.
- Add a Summary Sheet to an Excel Report — where file-level provenance usually belongs.
- Validate an Excel Report Before Sending It — the gate that should also strip internal notes.