Fix "ExcelWriter object has no attribute save" in pandas
Scripts written before 2023 often end their Excel export like this:
writer = pd.ExcelWriter("report.xlsx")
df.to_excel(writer, sheet_name="Data")
writer.save()
After upgrading pandas, the last line fails:
AttributeError: 'OpenpyxlWriter' object has no attribute 'save'
— or 'XlsxWriter' object has no attribute 'save', depending on the engine. The method was deprecated in pandas 1.5 and removed in 2.0. Nothing is wrong with the rest of the code; the file is now written when the writer is closed. This guide, part of Troubleshooting Common Python Excel Errors, shows the replacement and the related API changes that tend to break the same scripts.
Why the method was removed
save() and close() had drifted into doing nearly the same job, and having both invited the pattern that caused real bugs: calling save() part-way, continuing to write, and never closing the file handle. pandas deprecated save() in 1.5 with a warning pointing at close(), and removed it in 2.0 along with the ability to replace the writer's internal book, sheets and handles — attributes that exposed engine internals and let callers put the writer into states pandas could not manage. The replacement is the standard Python idiom for anything that holds a file open: a context manager. If your scripts printed a FutureWarning mentioning save before the upgrade, that was the notice; running jobs with warnings visible in their logs is the cheapest way to see the next removal coming.
Prerequisites
pip install "pandas>=2" openpyxl xlsxwriter
import pandas as pd
df = pd.DataFrame({"region": ["North", "South"], "sales": [410, 388]})
Step 1 — Use a with block
The recommended form opens the writer as a context manager. Everything inside the block writes into the workbook, and leaving the block closes the writer, which saves the file:
with pd.ExcelWriter("report.xlsx") as writer:
df.to_excel(writer, sheet_name="Data", index=False)
df.describe().to_excel(writer, sheet_name="Stats")
print(pd.ExcelFile("report.xlsx").sheet_names) # ['Data', 'Stats']
The with form also fixes a problem the old code had quietly: if an exception occurred between creating the writer and calling save(), the file handle stayed open and the workbook was never written, or was left half-written. The context manager closes the writer however the block exits.
Step 2 — Or call close()
When the writer is created in one place and finished in another — a class that builds a report over several method calls, for instance — call close() where save() used to be:
writer = pd.ExcelWriter("report_class.xlsx", engine="openpyxl")
df.to_excel(writer, sheet_name="Data", index=False)
writer.close() # was: writer.save()
Treat close() as final. Old code sometimes called save() in the middle and kept writing; after close() the writer is finished, and writing again requires a new writer — in append mode if the file should keep what was already saved (Step 4).
Step 3 — Keep formatting code that uses the engine
Many exports format the sheets through the engine after writing — column widths, number formats, frozen panes. That still works: writer.book and writer.sheets are available inside the block, so move the formatting code in with the writes:
with pd.ExcelWriter("formatted.xlsx", engine="xlsxwriter") as writer:
df.to_excel(writer, sheet_name="Data", index=False)
book, sheet = writer.book, writer.sheets["Data"]
money = book.add_format({"num_format": "#,##0"})
sheet.set_column("A:A", 14)
sheet.set_column("B:B", 12, money)
sheet.freeze_panes(1, 0)
Reading writer.book and writer.sheets is fine. What changed is assigning to them, which is the next fix.
Step 4 — Replace writer.book assignment with append mode
The other removed pattern appended to an existing workbook by loading it with openpyxl and handing it to the writer:
book = load_workbook("report.xlsx")
writer = pd.ExcelWriter("report.xlsx", engine="openpyxl")
writer.book = book
writer.sheets = {ws.title: ws for ws in book.worksheets}
In pandas 2 both assignments fail — book and sheets are read-only properties, and the first line raises AttributeError: property 'book' of 'OpenpyxlWriter' object has no setter. Append mode does the loading for you, and if_sheet_exists states what should happen when a sheet name is already taken:
extra = pd.DataFrame({"region": ["East"], "sales": [295]})
with pd.ExcelWriter("report.xlsx", mode="a", engine="openpyxl", if_sheet_exists="replace") as writer:
extra.to_excel(writer, sheet_name="Latest", index=False)
df.to_excel(writer, sheet_name="Data", index=False) # replaces the old Data sheet
print(pd.ExcelFile("report.xlsx").sheet_names) # ['Data', 'Stats', 'Latest']
Append mode needs the openpyxl engine and an existing file. The options for if_sheet_exists — error, new, replace and overlay — are compared in Write a DataFrame to a Specific Cell in Excel with pandas, and appending rows to an existing sheet in Append a DataFrame to an Existing Excel File with pandas.
Step 5 — Migrate a writer shared across a report class
Larger scripts often wrap the writer in a class: one method opens it, others add sheets, and a final method used to call save(). The clean migration makes the class itself a context manager, so callers get the same guarantee as a with block:
class RegionReport:
def __init__(self, path: str):
self.path = path
self.writer = None
def __enter__(self):
self.writer = pd.ExcelWriter(self.path, engine="xlsxwriter")
return self
def add(self, name: str, frame: pd.DataFrame) -> None:
frame.to_excel(self.writer, sheet_name=name, index=False)
self.writer.sheets[name].set_column(0, len(frame.columns) - 1, 14)
def __exit__(self, exc_type, exc, tb):
self.writer.close() # was: self.writer.save()
return False # let exceptions propagate
with RegionReport("regions.xlsx") as report:
report.add("North", df[df["region"] == "North"])
report.add("South", df[df["region"] == "South"])
print(pd.ExcelFile("regions.xlsx").sheet_names) # ['North', 'South']
__exit__ runs whether the block finishes normally or raises, so the writer is always closed and the file handle released. Returning False means errors still surface to the caller — the report is saved as far as it got, and the job fails visibly rather than silently.
Step 6 — Find every old call in a codebase
A script that fails on save() usually has siblings. Search the whole project once and fix them together, rather than one failed job at a time:
import re
from pathlib import Path
OLD_API = {
r"\.save\(\s*\)": "writer.save() -> with block or writer.close()",
r"\.book\s*=(?!=)": "writer.book = ... -> ExcelWriter(mode='a')",
r"\.sheets\s*=(?!=)": "writer.sheets = ... -> ExcelWriter(mode='a')",
r"\.handles\b": "writer.handles was removed",
}
Path("legacy_job.py").write_text("w = pd.ExcelWriter('x.xlsx')\nw.book = wb\nw.save()\n")
for path in Path(".").rglob("*.py"):
text = path.read_text(encoding="utf-8", errors="ignore")
for pattern, advice in OLD_API.items():
for m in re.finditer(pattern, text):
line = text.count("\n", 0, m.start()) + 1
print(f"{path}:{line}: {advice}")
The .save() pattern also matches wb.save() on openpyxl workbooks, which is still correct — openpyxl's own save(path) takes a filename, so calls with an argument are skipped by the pattern, but review each hit rather than replacing blindly.
Pin versions so the next upgrade is deliberate
The error appeared because pandas was upgraded underneath working code — usually by an unpinned pip install pandas on a new machine or in a rebuilt container. Pin the versions a job was tested with in its requirements file, and upgrade them on purpose, with the job's tests run against the new versions first. A small smoke test that writes and reads back a workbook, as in Test Excel Output with pytest, catches this class of break before the scheduled run does.
Common pitfalls
- Calling close() twice. Closing an already-closed writer is harmless, but writing after close is not; restructure code that wrote after the old
save(). - Mixing a with block and an explicit close(). Unnecessary — the block closes the writer.
- Append mode with xlsxwriter. xlsxwriter cannot open existing files; pandas raises if you combine
mode="a"with it. - Notebook cells that never close. In Jupyter, a writer created in one cell and never closed keeps the file locked and unwritten. Use a
withblock inside a single cell. - Assuming the old code saved on exceptions. It often did not. The
withform is not just a rename; it is more reliable than the code it replaces.
Conclusion
ExcelWriter.save() no longer exists in pandas 2. Use with pd.ExcelWriter(...) as writer: so the file is written when the block ends, or writer.close() where the writer spans functions. Keep engine formatting code inside the block, replace writer.book = ... and writer.sheets = ... with mode="a" and if_sheet_exists, search the codebase for the old calls, and pin library versions so future removals arrive on your schedule.
Frequently asked questions
Why did ExcelWriter.save() stop working? It was deprecated in pandas 1.5 and removed in pandas 2.0. Saving now happens when the writer is closed — at the end of a with block or when you call writer.close().
Is writer.close() the same as the old save()? For practical purposes, yes. close() writes the file and releases it. The difference is that you cannot keep writing after close(), whereas old code sometimes called save() and carried on.
What replaces writer.book = load_workbook(...)? Open the writer in append mode — pd.ExcelWriter(path, mode="a", engine="openpyxl") — and choose what happens to existing sheets with if_sheet_exists. pandas loads the existing workbook for you.
Can I still use xlsxwriter or openpyxl features on the sheets? Yes. writer.book and writer.sheets are still available to read inside the with block, so formatting code that calls the engine's API on them keeps working.
Related
- Up one level: Troubleshooting Common Python Excel Errors — every common error explained.
- Writing DataFrames to Excel with Pandas — the writer in depth.
- Write Multiple DataFrames to One Excel File — multi-sheet writers.
- openpyxl vs xlsxwriter vs pandas.ExcelWriter — choosing the engine.
More on errors & fixes
- FixHandle Permission Denied When Writing Excel in Python
- How-toWhat openpyxl Loses When You Save an Existing Workbook
- FixFix Excel Serial Numbers Showing Instead of Dates
- How-toHandle Daylight Saving Time in Excel Timestamps
- How-toHandle Microsoft Graph Throttling in Excel Automation
- GuideError Handling and Logging in Excel Automation