Fix openpyxl Number Format Not Applied
The code sets a format, the save succeeds, and the workbook opens with raw numbers:
>>> cell = worksheet["C2"]
>>> cell.number_format = "£#,##0.00"
>>> workbook.save("out/report.xlsx")
# Excel shows 1204.5, not £1,204.50
Reading the file back is the confusing part — the format is often there:
>>> load_workbook("out/report.xlsx")["Sheet1"]["C2"].number_format
'£#,##0.00'
Which means the assignment worked and something else is wrong: the format landed on a different cell than the one holding the value, or on a different object than the one that was saved, or the code string is not one Excel accepts.
Root Cause
number_format is a per-cell style attribute. Four things routinely break the link between the cell you styled and the cell the reader sees.
Formatting is applied to a worksheet obtained before pandas wrote to it, so the styled cells are overwritten by the write. Or the loop starts at row 1 and styles the header instead of the data. Or the workbook was opened in write_only or read_only mode, where cell styles are not retained. Or the format code itself is invalid — Excel silently falls back to General when it cannot parse a code, and openpyxl does not validate.
Minimal Diagnostic
Read the saved file and print what each cell actually carries. This separates "the format was never written" from "the format is on the wrong cells".
# pip install "openpyxl>=3.1,<4"
from pathlib import Path
from openpyxl import load_workbook
OUT = Path("out/report.xlsx")
def format_map(xlsx_path: Path, sheet: str | None = None, rows: int = 6) -> None:
"""Print value and number_format for the first rows of every column."""
if not xlsx_path.exists():
raise FileNotFoundError(f"No such workbook: {xlsx_path}")
try:
book = load_workbook(xlsx_path) # not read_only: styles are needed
except Exception as exc:
raise RuntimeError(f"Could not open {xlsx_path}: {exc}") from exc
worksheet = book[sheet] if sheet else book.active
for row in worksheet.iter_rows(min_row=1, max_row=rows):
for cell in row:
if cell.value is None:
continue
print(f"{cell.coordinate:>5} {str(cell.value)[:22]:<24} {cell.number_format}")
book.close()
if __name__ == "__main__":
format_map(OUT)
A1 booked_on General
B1 amount General
A2 2026-07-15 00:00:00 General <- date written, format missing
B2 1204.5 £#,##0.00 <- format landed here correctly
load_workbook(..., read_only=True) returns cells without style information, so the diagnostic must open the file normally — using read-only mode here reports General for everything and sends you chasing a bug that is not there.
Fix: Style After Writing, and Skip the Header
The most common case by far is pandas plus openpyxl. Grab the worksheet from the writer after to_excel has run, and start the loop at row 2.
# pip install "pandas>=2.2,<3" "openpyxl>=3.1,<4"
from pathlib import Path
import pandas as pd
OUT = Path("out/report.xlsx")
FORMATS = {
"booked_on": "dd/mm/yyyy",
"amount": "£#,##0.00",
"share": "0.0%",
"account": "@", # text: preserves leading zeros
}
def write_with_formats(frame: pd.DataFrame, dest: Path, sheet: str = "Data") -> Path:
"""Write a frame, then apply per-column number formats to the data rows."""
dest.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(dest, engine="openpyxl") as writer:
frame.to_excel(writer, sheet_name=sheet, index=False)
worksheet = writer.sheets[sheet] # AFTER to_excel, not before
for position, column in enumerate(frame.columns, start=1):
code = FORMATS.get(column)
if code is None:
continue
for (cell,) in worksheet.iter_rows(
min_col=position, max_col=position,
min_row=2, # row 1 is the header
max_row=worksheet.max_row,
):
cell.number_format = code
return dest
if __name__ == "__main__":
df = pd.DataFrame({
"booked_on": pd.to_datetime(["2026-07-15", "2026-07-16"]),
"amount": [1204.5, 98.0],
"share": [0.125, 0.4],
"account": ["00417", "00992"],
})
print(write_with_formats(df, OUT))
Two ordering rules make this reliable. Obtain the worksheet from writer.sheets after the data is written, because a worksheet captured earlier refers to an object pandas then replaces. And style by column position derived from frame.columns, not by a hard-coded letter, so an added column upstream cannot silently shift the currency format onto the date.
Variant Fix 1: write_only Mode Discards Styles
Workbook(write_only=True) streams rows for memory efficiency and does not keep a cell grid, so attributes set on a cell after appending are lost. Build styled cells before appending them:
# pip install "openpyxl>=3.1,<4"
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell
book = Workbook(write_only=True)
sheet = book.create_sheet("Data")
for amount in [1204.5, 98.0]:
cell = WriteOnlyCell(sheet, value=amount)
cell.number_format = "£#,##0.00" # set BEFORE append, on a WriteOnlyCell
sheet.append([cell])
book.save("out/stream.xlsx")
For large sheets, xlsxwriter is usually the better answer — it applies a format to an entire column in one call and is faster on the write path than styling cells individually:
# pip install xlsxwriter "pandas>=2.2,<3"
import pandas as pd
with pd.ExcelWriter("out/big.xlsx", engine="xlsxwriter") as writer:
frame.to_excel(writer, sheet_name="Data", index=False)
book, sheet = writer.book, writer.sheets["Data"]
money = book.add_format({"num_format": "£#,##0.00"})
sheet.set_column("B:B", 14, money) # width and format for the whole column
Variant Fix 2: The Format Code Is Not Valid
Excel accepts a specific grammar. Codes copied from documentation, from a locale-specific dialog, or written with the wrong separators fall back to General without complaint.
| Intent | Valid code | Common mistake |
|---|---|---|
| Currency | £#,##0.00 | £#.##0,00 (continental separators) |
| Thousands | #,##0 | #.##0 |
| Percentage | 0.0% | %0.0 |
| Date | dd/mm/yyyy | DD/MM/YYYY in some locales |
| Text | @ | "text" |
| Negatives in red | #,##0.00;[Red]-#,##0.00 | #,##0.00;[RED]-#,##0.00 |
| Accounting | _-£* #,##0.00_-;-£* #,##0.00_-;_-£* "-"??_-;_-@_- | truncating the four sections |
A format string has up to four semicolon-separated sections — positive, negative, zero, text — and supplying two means the second applies to negatives, not to zeros. If a code renders correctly for positive values and oddly for zeros, that is the cause.
# pip install "openpyxl>=3.1,<4"
from openpyxl.styles.numbers import BUILTIN_FORMATS
# The built-in ids Excel guarantees; use one when portability matters more than exact styling
for index, code in sorted(BUILTIN_FORMATS.items())[:12]:
print(index, code)
Variant Fix 3: The Value Is Text, So No Numeric Format Can Apply
A number stored as a string ignores every numeric format — the cell is left-aligned and shows exactly what was written.
# pip install "pandas>=2.2,<3" "openpyxl>=3.1,<4"
from openpyxl import load_workbook
book = load_workbook("out/report.xlsx")
sheet = book.active
for row in sheet.iter_rows(min_row=2, min_col=2, max_col=2):
for cell in row:
if isinstance(cell.value, str):
# A numeric format on text does nothing — convert the value first
try:
cell.value = float(cell.value.replace(",", "").replace("£", ""))
cell.number_format = "£#,##0.00"
except ValueError:
pass # genuinely textual, leave it alone
book.save("out/report.xlsx")
The inverse case matters just as much: identifiers like 00417 must stay text with the @ format, or Excel strips the leading zeros the moment the file is opened. The date-side equivalent of this whole class of problem is covered in fix Excel dates showing as numbers in pandas.
Verification
Assert on the saved file, not on the in-memory objects — that is the only check that reflects what a recipient sees.
# pip install "openpyxl>=3.1,<4"
from pathlib import Path
from openpyxl import load_workbook
def assert_formats(xlsx_path: Path, expected: dict[str, str], sheet: str = "Data") -> None:
"""Every data cell in a named column must carry the expected format code."""
book = load_workbook(xlsx_path) # styles need a normal open
try:
worksheet = book[sheet]
headers = {cell.value: cell.column for cell in worksheet[1]}
for column, code in expected.items():
assert column in headers, f"no column named {column!r}"
position = headers[column]
wrong = [
cell.coordinate
for (cell,) in worksheet.iter_rows(min_col=position, max_col=position, min_row=2)
if cell.value is not None and cell.number_format != code
]
assert not wrong, (
f"{column}: {len(wrong)} cell(s) not formatted as {code!r}, e.g. {wrong[0]}"
)
print(f"{len(expected)} column format(s) verified in {xlsx_path.name}")
finally:
book.close()
if __name__ == "__main__":
assert_formats(Path("out/report.xlsx"),
{"booked_on": "dd/mm/yyyy", "amount": "£#,##0.00", "share": "0.0%"})
Reading the header row to find column positions, rather than hard-coding letters, keeps the assertion honest when a column is inserted upstream. Wire it into the same job that produces the report — the reporting workflow in automating Excel report generation is where a silent formatting regression is most likely to reach an audience.
FAQ
Why does the format appear when I read the file back but not in Excel?
Almost always because the format is on cells that are empty or on the header row, while the populated data cells still have General. The diagnostic prints value and format together, which makes that obvious at a glance.
Does cell.style do the same thing?cell.style assigns a named style covering font, fill, border and number format together. Mixing it with cell.number_format means whichever is set last wins, so pick one mechanism per cell.
Can I set a format for a whole column with openpyxl?
Not in a way Excel honours for existing cells — column_dimensions[...].number_format applies to new cells only. Loop the cells, or switch to xlsxwriter and set_column, which writes a genuine column format.
Why do my currency symbols look wrong on another machine?
Currency and date codes are interpreted against the viewer's locale. Use unambiguous codes (yyyy-mm-dd), or the locale-neutral built-in ids, when the recipient's regional settings are unknown.
Formats survive but conditional colours do not — why?
Conditional formatting is a separate feature stored per worksheet, not a cell attribute, and openpyxl needs worksheet.conditional_formatting.add(...). Setting number_format will never produce colour rules.
Related
- Working with Excel Dates and Number Formats — format codes and the value-versus-presentation model
- Fix Excel Dates Showing as Numbers in pandas — the read-side counterpart
- Writing Excel Formulas and Charts with openpyxl — styling, formulas and charts together
- Automating Excel Report Generation — where formatted output is produced on a schedule
- Fix openpyxl Read-Only Mode Error — the mode that also strips styles