Fix
Getting Started With Python Excel AutomationDeep dive

Fix "ExcelWriter object has no attribute save" in pandas

pandas 2.0 removed ExcelWriter.save(), so old scripts fail with AttributeError. Replace it with a with block or close(), and fix the other removed writer APIs that usually break at the same time — book assignment, sheets assignment and handles.

Scripts written before 2023 often end their Excel export like this:

Text
writer = pd.ExcelWriter("report.xlsx")
df.to_excel(writer, sheet_name="Data")
writer.save()

After upgrading pandas, the last line fails:

Text
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.

Replacing writer.save() with a with block The old pattern creates a writer, writes frames and calls save, which no longer exists. The new pattern opens the writer in a with block; leaving the block closes the writer and writes the file, even if an exception interrupts the code. writer = pd.ExcelWriter(path) df.to_excel(writer) writer.save() with pd.ExcelWriter(path) as writer: df.to_excel(writer) # saved when the block ends pandas < 2.0 pandas ≥ 2.0 with … as writer

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

Bash
pip install "pandas>=2" openpyxl xlsxwriter
Python
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:

Python
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:

Python
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).

Choosing the replacement for writer.save() If the writer is created and finished in one block of code, use a with statement. If it is created in one function and finished in another, call writer.close() at the end, ideally in a finally block. If the old code assigned writer.book to add sheets to an existing file, switch to append mode. How does the old code use the writer? one block with statement saves on exit across functions writer.close() in a finally block writer.book = load_workbook mode="a" pandas loads the file

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:

Python
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:

Text
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:

Python
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:

Python
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:

Python
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.

ExcelWriter methods and attributes that changed in pandas 2 The save method was removed in favour of close or a with block. Assigning book and sheets is no longer allowed, replaced by append mode. Reading book and sheets still works. The handles attribute was removed. pandas 1.x pandas 2 and later writer.save() works removed — use close() with ExcelWriter(...) works works — preferred writer.book = wb works removed — mode="a" writer.sheets = {...} works removed — mode="a" reading writer.book / .sheets works works

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 with block inside a single cell.
  • Assuming the old code saved on exceptions. It often did not. The with form 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.

More on errors & fixes

More on errors & fixes →