Guide
Getting Started With Python Excel AutomationDeep dive

Close Excel Cleanly and Avoid Orphan COM Processes

Excel exits only when every reference is released. Learn the try/finally shape, CoInitialize on worker threads, and how to detect and kill just the instance you started.

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.

Why Quit does not always quit Excel exits only when the reference count reaches zero, so a worksheet object still held in Python keeps the hidden process alive after Quit returns. the reference count decides Quit() called asks Excel to close a reference remains a sheet or book object EXCEL.EXE lives on hidden, holding a lock if a call still works after Quit, the process never closed

Prerequisites

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

Python
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

Python
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 initialisation is per thread, not per process The main thread of a script usually has COM initialised implicitly, but a worker thread, a web request handler or a scheduler job must call CoInitialize itself and pair it with CoUninitialize. 1 Main thread of a script usually initialised implicitly 2 Worker or scheduler thread must call CoInitialize itself 3 Web request handler same rule — each request thread 4 Always pair it CoUninitialize in the finally block the error names CoInitialize and never mentions threads

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.

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

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

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

Python
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

SymptomCauseFix
Hidden EXCEL.EXE processes accumulateReferences still held when Quit() ranClose the workbook, del the objects, then quit
PermissionError opening a file the script wroteAn orphan still holds the lockClean up the orphans; see the detection snippet above
CoInitialize has not been calledCOM used on a thread that never initialised itpythoncom.CoInitialize() at the top of the thread
Quitting closes a colleague's workbookDispatch attached to their running instanceUse DispatchEx for a private instance
Cleanup skipped after an exceptionNo finally blockWrap the session in a helper like the one above
Excel reappears on the next run with a recovery promptThe previous process was killed with a file openClose 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.

Python
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()
One instance for many files, not one each Starting Excel costs one to three seconds, so opening twenty workbooks in a single instance is far cheaper than starting twenty instances, while the cleanup rules stay the same. an instance per file 20 startups 40 seconds of overhead 20 chances to orphan one instance, 20 files 1 startup seconds of overhead one finally block reuse move the finally outward, not the logic

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.