Fix
Getting Started With Python Excel AutomationDeep dive

Fix xlsxwriter "Exception caught in workbook destructor"

The xlsxwriter message "Exception caught in workbook destructor. Explicit close() may be required" comes from versions before 1.1, where an unclosed workbook was saved when garbage collected. Current versions print nothing and write nothing instead. Close every workbook explicitly, handle FileCreateError, and avoid writing half-built reports from a with block.

This message appears in older scripts and older environments:

Text
Exception ignored in: <function Workbook.__del__ at 0x7f...>
Exception: Exception caught in workbook destructor. Explicit close() may be required for workbook.

It means xlsxwriter tried to save the workbook when Python destroyed the Workbook object, because the code never called close(), and saving failed. In current xlsxwriter the message no longer exists, but the underlying mistake does, and it has become quieter: a workbook that is never closed is simply never written. This page explains both, and how to close workbooks so the file is always written or the failure is always reported. It is part of Troubleshooting Common Python Excel Errors.

Prerequisites

Bash
pip install xlsxwriter pandas

The examples use xlsxwriter 3.2. xlsxwriter builds the whole file in memory (or temporary files) and writes the .xlsx only in close(); nothing reaches the target path before that.

Where the message comes from

In xlsxwriter 1.0 and earlier, Workbook had a destructor that called close() if the code had not, and turned any error it hit into this exception:

Python
# xlsxwriter 1.0.x, workbook.py
def __del__(self):
    """Close file in destructor if it hasn't been closed explicitly."""
    try:
        if not self.fileclosed:
            self.close()
    except:
        raise Exception("Exception caught in workbook destructor. "
                        "Explicit close() may be required for workbook.")

Exceptions raised in a destructor cannot propagate, so Python printed it as "Exception ignored" and carried on, and the real error — a missing directory, a file locked by Excel — was swallowed. Version 1.1 removed the destructor. If you see this message, the environment has a very old xlsxwriter; upgrading is worthwhile, but the fix in the code is the same either way. Check which version the failing job actually runs, which is not always the one on your laptop:

Bash
python -c "import xlsxwriter; print(xlsxwriter.__version__)"

Old versions usually survive in long-lived servers, frozen virtual environments and executables built years ago. Pin a current version in the project's requirements, as described in Set Up an Excel Automation Project with uv, so every environment that runs the report gets the same behaviour. After upgrading, a missing close() no longer produces the message at all, which makes the next two steps essential rather than optional.

An unclosed workbook, then and now Two panels. In xlsxwriter 1.0 and earlier, the destructor saved an unclosed workbook when it was garbage collected and printed this exception if saving failed. From version 1.1 there is no destructor, so an unclosed workbook is never written and nothing is printed. xlsxwriter 1.0 and earlier destructor calls close() errors become this message real cause is hidden xlsxwriter 1.1 and later no destructor no file written no message at all

Step 1 — See the silent version

In a current environment, forgetting close() produces no error and no file:

Python
import gc
import os
import xlsxwriter

def build_report():
    wb = xlsxwriter.Workbook("never_closed.xlsx")
    wb.add_worksheet().write("A1", "Sales")
    # no wb.close()

build_report()
gc.collect()
print("file written:", os.path.exists("never_closed.xlsx"))

The function returns normally, the process exits with status 0, and any monitoring that only watches exit codes sees a healthy job. A scheduled job with this bug "succeeds" every night and delivers nothing, or, worse, attaches last night's file that happens to be lying in the folder.

Step 2 — Close with a context manager

Workbook is a context manager: with calls close() when the block ends, including when it ends because of an error. That guarantees the file is written or the failure is raised at a known place:

Python
with xlsxwriter.Workbook("report.xlsx") as wb:
    ws = wb.add_worksheet("Sales")
    ws.write_row("A1", ["Region", "Total"])
    ws.write_row("A2", ["North", 12500])
print("file written:", os.path.exists("report.xlsx"))

For pandas, the same applies to ExcelWriter. Its old save() method was removed in pandas 2.0; use with or close():

Python
import pandas as pd

with pd.ExcelWriter("pandas_report.xlsx", engine="xlsxwriter") as writer:
    pd.DataFrame({"Region": ["North"], "Total": [12500]}).to_excel(writer, index=False)
print("has save():", hasattr(writer, "save"), "| file written:", os.path.exists("pandas_report.xlsx"))

Step 3 — Handle the errors close() raises

Because the file is only created in close(), that is where file-system problems surface, as xlsxwriter.exceptions.FileCreateError. The usual causes are a directory that does not exist, a folder without write permission, and on Windows, the same file open in Excel:

Python
from xlsxwriter.exceptions import FileCreateError

for path in ["missing_dir/report.xlsx", "locked/report.xlsx"]:
    wb = xlsxwriter.Workbook(path)
    wb.add_worksheet().write("A1", "x")
    try:
        wb.close()
    except FileCreateError as e:
        print(f"{path}: {e}")

For an interactive script, a retry prompt is kind to the user who left the file open. For a scheduled job, create the directory first and let the error fail the job loudly:

Python
from pathlib import Path

out = Path("output/2026-09/report.xlsx")
out.parent.mkdir(parents=True, exist_ok=True)
with xlsxwriter.Workbook(out) as wb:
    wb.add_worksheet().write("A1", "x")
print("written to", out)
What happens in close() The workbook is assembled in memory. close() creates the target file, which is where missing directories, permissions and files locked by Excel raise FileCreateError, then writes the parts and zips them. Only after close() returns is the file complete. Build in memory add_worksheet / write close() or end of with Create file FileCreateError here Write and zip XML parts Complete .xlsx safe to send

Step 4 — Don't ship a half-built report

The context manager closes the workbook even when the code inside the block fails, so an error halfway through leaves a valid but incomplete file at the target path:

Python
try:
    with xlsxwriter.Workbook("half_built.xlsx") as wb:
        ws = wb.add_worksheet()
        ws.write("A1", "Summary")
        raise RuntimeError("database query failed")
except RuntimeError as e:
    print("error:", e, "| file exists anyway:", os.path.exists("half_built.xlsx"))

If a later step emails or uploads whatever is in the output folder, the recipients get a report with a title and no numbers. Build to a temporary name and rename only on success; os.replace is atomic on the same file system, so readers see either the old file or the complete new one:

Python
def write_report(path, build):
    tmp = Path(path).with_suffix(".tmp.xlsx")
    try:
        with xlsxwriter.Workbook(tmp) as wb:
            build(wb)
    except Exception:
        tmp.unlink(missing_ok=True)
        raise
    os.replace(tmp, path)

write_report("final.xlsx", lambda wb: wb.add_worksheet().write("A1", "complete"))
print(sorted(p for p in os.listdir(".") if p.startswith(("final", "half"))))

Step 5 — Check the file before delivering it

Closing correctly guarantees that a written file is complete; it does not guarantee that the file contains what the recipient expects. A last check before the email or upload step catches the rest: the file exists, was written by this run, opens, and has the sheets and rows it should. It costs a fraction of a second:

Python
import time
from openpyxl import load_workbook

def check_report(path, started, sheets, min_rows=2):
    p = Path(path)
    assert p.exists(), f"{p} was not written"
    assert p.stat().st_mtime >= started, f"{p} is left over from an earlier run"
    wb = load_workbook(p, read_only=True)
    try:
        missing = set(sheets) - set(wb.sheetnames)
        assert not missing, f"{p} is missing sheets {missing}"
        for name in sheets:
            rows = sum(1 for _ in wb[name].iter_rows(values_only=True))
            assert rows >= min_rows, f"{p}:{name} has only {rows} rows"
    finally:
        wb.close()
    return "ok"

started = time.time() - 1
with xlsxwriter.Workbook("checked.xlsx") as wb:
    ws = wb.add_worksheet("Sales")
    ws.write_row("A1", ["Region", "Total"])
    ws.write_row("A2", ["North", 12500])
print(check_report("checked.xlsx", started, ["Sales"]))
try:
    check_report("half_built.xlsx", started, ["Sales"])
except AssertionError as e:
    print("blocked:", e)

The modification-time check is what catches the most embarrassing failure: a job whose workbook was never closed, sending the file an earlier run left behind. The sheet and row checks catch the half-built file from Step 4, which here does not even have a Sales sheet.

From symptom to cause A decision diagram. The destructor message means an old xlsxwriter and a missing close. No file at all means close was never called. FileCreateError means the directory, permissions or a file open in Excel. A file with missing data means an error inside the with block. What do you see? destructor message Old xlsxwriter add close(), upgrade no file close() never ran use with FileCreateError Path problem dir, permission, lock file but no data Error mid-build temp file + rename

Common pitfalls

  • Relying on garbage collection. Nothing writes an unclosed workbook in current xlsxwriter; always close.
  • Calling close() twice. The second call only warns "Calling close() on already closed file"; harmless, but a sign that close is in two code paths.
  • writer.save() in pandas. Removed in pandas 2.0; use with pd.ExcelWriter(...) or close().
  • Catching everything around close(). A bare except hides FileCreateError, which is exactly the error you need to see.
  • Partial files. A with block writes the file even after an error; write to a temporary name and rename on success.
  • Jupyter notebooks. A workbook created in one cell and never closed stays in memory until the kernel restarts; the file never appears.

Conclusion

"Exception caught in workbook destructor" is old xlsxwriter's way of saying the workbook was never explicitly closed and the automatic save failed; newer versions say nothing and write nothing. Close every workbook with a with block or close(), catch FileCreateError where the file is created, create output directories first, and write to a temporary name that is renamed only when the whole report has been built.

Frequently asked questions

What does "Explicit close() may be required for workbook" mean? The workbook was never closed, so xlsxwriter versions up to 1.0 tried to save it when the object was garbage collected, and that save failed. Call close() yourself, or use a with block, and the real error will be raised where you can see it.

Why does xlsxwriter not create my file? The file is only written when close() runs. If the code never calls it, current versions of xlsxwriter write nothing and report nothing.

What is FileCreateError in xlsxwriter? The exception close() raises when it cannot create the output file: the directory does not exist, there is no write permission, or on Windows the file is open in Excel.

How do I save a pandas ExcelWriter now that save() is gone? Use it as a context manager, with pd.ExcelWriter(path) as writer:, or call writer.close() when finished.

More on errors & fixes

More on errors & fixes →