Close Excel Cleanly and Avoid Orphan COM Processes
Every other library on this site releases what it holds when a variable goes out of scope. COM does
not: EXCEL.EXE stays alive while any reference to it exists anywhere in the process, and an
orphaned instance keeps a lock on whatever workbook it had open. On a machine running a report
hourly, that becomes a dozen hidden Excel processes and a job that fails with a file-in-use error
nobody can explain. This guide, part of
Automating Excel with COM and pywin32,
covers the lifecycle rules that prevent it.
Prerequisites
pip install pywin32 psutil
psutil is only needed for the detection and last-resort cleanup sections at the end.
Why Quit() sometimes does nothing
Excel exits when its reference count reaches zero. Quit() asks it to close, but if a Python
variable still points at a workbook, a worksheet or a range inside that application, the count is
not zero and the process remains — invisible, because Visible was set to False.
import win32com.client as win32
excel = win32.DispatchEx("Excel.Application")
excel.Visible = False
book = excel.Workbooks.Open(r"C:\data\orders.xlsx")
sheet = book.Sheets("Data") # a live reference into the application
excel.Quit() # returns, but EXCEL.EXE is still running
print(sheet.Range("A1").Value) # ...and this still works, which proves it
That last line is the tell. If a call succeeds after Quit(), the application never closed. The
fix is to release references in the reverse of the order you acquired them, innermost first.
The shape that always cleans up
import pythoncom
import win32com.client as win32
def with_workbook(path, work):
pythoncom.CoInitialize()
excel = win32.DispatchEx("Excel.Application")
excel.Visible = False
excel.DisplayAlerts = False
book = None
try:
book = excel.Workbooks.Open(path, UpdateLinks=0)
return work(book)
finally:
if book is not None:
book.Close(SaveChanges=False)
excel.Quit()
del book
del excel
pythoncom.CoUninitialize()
Four details do the work. DispatchEx starts a private instance, so quitting it cannot close a
workbook somebody was using. The finally runs whether or not work raised. The explicit del
statements drop the last Python references immediately rather than waiting for garbage collection.
And CoUninitialize tears down the COM apartment for the thread, which is what actually lets the
process go.
Threads, servers and CoInitialize
COM is initialised per thread. A plain script's main thread usually has it done implicitly, but any
other thread does not — and the failure is an error mentioning CoInitialize that says nothing
about threads at all.
import threading
import pythoncom
import win32com.client as win32
def worker(path):
pythoncom.CoInitialize() # required in this thread
try:
excel = win32.DispatchEx("Excel.Application")
try:
book = excel.Workbooks.Open(path)
book.RefreshAll()
book.Close(SaveChanges=True)
finally:
excel.Quit()
finally:
pythoncom.CoUninitialize()
threading.Thread(target=worker, args=(r"C:\data\a.xlsx",)).start()
The same requirement applies inside a Flask or FastAPI request handler, an APScheduler job, and a Celery worker — anywhere the code runs on a thread the interpreter created rather than the one it started on. Running two Excel instances concurrently from different threads works, but it is worth noting that Excel is not designed for it and mysterious failures under concurrency are common; serialising the work with a lock is usually the calmer choice.
Detecting the orphans you already have
Before adding cleanup, it is worth measuring the problem. Counting hidden Excel processes takes three lines and often produces a surprising number on a machine that has been running scheduled jobs for a while.
import psutil
orphans = [p for p in psutil.process_iter(["pid", "name", "create_time"])
if p.info["name"] and p.info["name"].lower() == "excel.exe"]
for process in orphans:
print(process.info["pid"], process.info["create_time"])
print(f"{len(orphans)} Excel process(es) running")
A process created hours ago on a machine where nobody has Excel open is an orphan. One created thirty seconds ago probably belongs to the job that is running right now, which is exactly why a blanket kill is the wrong tool.
Killing only what you started
When cleanup has to be guaranteed — an unattended machine, a job that must not leave locks — record the process ID of the instance you started and target only that one.
import win32process
import win32com.client as win32
excel = win32.DispatchEx("Excel.Application")
_, own_pid = win32process.GetWindowThreadProcessId(excel.Hwnd)
try:
...
finally:
try:
excel.Quit()
finally:
import psutil
if psutil.pid_exists(own_pid):
psutil.Process(own_pid).terminate() # only ours
excel.Hwnd gives the application window handle even when it is hidden, and
GetWindowThreadProcessId turns it into a process ID. Terminating that one process cannot disturb a
colleague's spreadsheet, which a taskkill /im excel.exe most certainly can.
What an orphan actually costs
It is tempting to treat a stray process as untidiness rather than a defect, so it is worth being precise about what it breaks. An orphaned instance holds an exclusive lock on every workbook it had open, which means the next run of the same job cannot write its output and fails with a permission error naming a file that looks perfectly free in Explorer. It also holds several hundred megabytes; after a dozen hourly runs, a modest server is swapping.
The subtler cost is that a later Dispatch call may attach to the orphan rather than starting a
fresh instance. The script then inherits whatever state the previous run left behind — calculation
set to manual, alerts suppressed, a half-open workbook, an add-in that failed to load — and produces
output that is wrong in ways that do not reproduce anywhere else. That is the failure that takes
days to diagnose, and it is entirely prevented by DispatchEx plus a reliable quit.
Because the symptoms surface elsewhere, orphan cleanup is worth treating as a property of the job rather than a tidy-up at the end. A run that cannot guarantee it released Excel should be treated as a failed run even if it produced a file, because the machine is now in a state the next run does not expect.
Verifying cleanup as part of the job
The cheapest check is to count Excel processes before and after, and to log the difference. On a dedicated automation machine the count should return to whatever it started at; anything else is a leak worth investigating while the change that caused it is still fresh.
import logging
import psutil
def excel_count() -> int:
return sum(1 for p in psutil.process_iter(["name"])
if (p.info["name"] or "").lower() == "excel.exe")
before = excel_count()
try:
run_report()
finally:
after = excel_count()
if after > before:
logging.warning("leaked %d Excel process(es): %d -> %d", after - before, before, after)
Three lines of instrumentation turn an invisible defect into a warning in the log the morning after it is introduced. Pairing that with the run-level logging in Log Python Excel Script Output to a File gives a record that answers the question a week later, when the permission error finally appears and somebody asks when it started.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Hidden EXCEL.EXE processes accumulate | References still held when Quit() ran | Close the workbook, del the objects, then quit |
PermissionError opening a file the script wrote | An orphan still holds the lock | Clean up the orphans; see the detection snippet above |
CoInitialize has not been called | COM used on a thread that never initialised it | pythoncom.CoInitialize() at the top of the thread |
| Quitting closes a colleague's workbook | Dispatch attached to their running instance | Use DispatchEx for a private instance |
| Cleanup skipped after an exception | No finally block | Wrap the session in a helper like the one above |
| Excel reappears on the next run with a recovery prompt | The previous process was killed with a file open | Close workbooks with SaveChanges=False before terminating |
Performance and scale
Starting Excel is expensive — typically one to three seconds, more on a cold machine — so a job that
processes twenty workbooks should start one instance and open twenty files, not start twenty
instances. The lifecycle rules do not change; the finally simply moves outward.
import win32com.client as win32
excel = win32.DispatchEx("Excel.Application")
excel.Visible = False
excel.DisplayAlerts = False
try:
for path in paths:
book = excel.Workbooks.Open(path, UpdateLinks=0)
try:
book.RefreshAll()
book.Save()
finally:
book.Close(SaveChanges=False)
finally:
excel.Quit()
The one thing to watch in a long-running loop is that Excel's memory does not return fully between workbooks. On a batch of several hundred files, restarting the application every fifty or so keeps the footprint flat — inelegant, but considerably cheaper than the alternative of discovering the limit at three in the morning.
Conclusion
Treat an Excel instance the way you would treat a file handle or a database connection: acquire it
explicitly, release it in a finally, and never assume the garbage collector will do it for you.
Use DispatchEx so the instance is yours to quit, close workbooks before quitting, drop the Python
references, and call CoInitialize in any thread that is not the one the interpreter started on.
For unattended machines, record the process ID at the start so cleanup can be guaranteed without
touching anybody else's work.
Frequently asked questions
Why does Excel keep running after Quit()? Because Quit only closes the application when nothing else holds a reference to it. A worksheet or workbook object still alive in Python keeps the process open, and in an interactive session the traceback of a caught exception holds references too.
Is killing EXCEL.EXE a reasonable fallback? As a last resort in a dedicated automation account, yes — but never blindly, because it will also close a workbook a person had open. Kill only the process ID you started, which you can obtain from the application's Hwnd before you begin.
Does the with statement help? pywin32 objects are not context managers, so no — you have to write the try/finally yourself, or wrap it once in a helper. xlwings does provide App as a context manager, which is one of the ergonomic reasons to prefer it.
What is CoInitialize for? COM has to be initialised per thread. The main thread of a simple script usually gets that for free through pywin32, but a worker thread, a Flask request handler or a scheduler job does not — and the resulting error mentions CoInitialize rather than threads.
Related
- Up one level: Automating Excel with COM and pywin32 — the session skeleton these rules protect.
- Handle COM Errors and Excel Dialog Prompts in Python — the hangs that leave orphans behind in the first place.
- Handle "Permission Denied" When Writing Excel in Python — the error an orphaned process causes downstream.
- xlwings vs pywin32 for Excel Automation — the wrapper that provides a context manager for this.
- Retry a Failed Excel Report Job in Python — making the surrounding job resilient once cleanup is reliable.