Automating Excel with COM and pywin32
When a job needs something only Excel can do — recalculate a model with Excel's own engine, run a macro somebody wrote in 2014, refresh a Power Query connection, print a range exactly as Excel would — the route is COM automation, and on Windows that means pywin32. This section of Getting Started with Python Excel Automation covers the raw interface: how to attach to Excel, how to move data across the boundary without paying for it a cell at a time, and how to make sure the process you started actually goes away.
COM is a different mental model from every other library on this site. openpyxl and xlsxwriter read and write a file; pywin32 sends instructions to a running program and reads its answers. That gives you everything Excel can do and none of the guarantees a file-level library provides — no headless mode, no cross-platform support, and a hard requirement that somebody eventually closes the application.
Attaching to Excel and the shape of a session
Every script follows the same skeleton: get an Excel.Application object, turn off the things that
make Excel interactive, do the work, and quit in a finally block so an exception cannot leave a
process behind.
import win32com.client as win32
excel = win32.gencache.EnsureDispatch("Excel.Application")
excel.Visible = False
excel.DisplayAlerts = False
try:
book = excel.Workbooks.Open(r"C:\reports\model.xlsx")
try:
book.Sheets("Summary").Range("B2").Value = 128400
book.Save()
finally:
book.Close(SaveChanges=False)
finally:
excel.Quit()
EnsureDispatch builds a cached type library the first time it runs, which gives you early binding:
named constants, faster calls and a much better error when a method does not exist.
win32.Dispatch works without the cache but resolves everything at runtime, so a typo becomes an
unhelpful COM exception rather than an AttributeError.
DisplayAlerts = False is not cosmetic. Without it a workbook that wants to ask "save changes?"
opens a modal dialog that nobody can see and nobody can dismiss, and the script hangs indefinitely.
Moving data across the boundary
The single most important performance fact about COM is that the cost is per call, not per cell. Reading one cell and reading a 20,000-cell block cost roughly the same, because the expensive part is crossing the process boundary. A loop that assigns cells one at a time will take minutes where a single range assignment takes under a second.
sheet = book.Sheets("Data")
# Read a whole block in one call — comes back as a tuple of tuples.
rows = sheet.Range("A2:D5001").Value
# Write a whole block in one call — needs a sequence of row sequences.
sheet.Range("F2:F5001").Value = [[value * 1.2] for value in column]
The shape is strict on the way in: Excel expects a two-dimensional structure, so a flat list assigned to a column range raises or fills only the first cell. Wrapping each value in its own list, as above, is the fix people spend an hour discovering.
Running the things that only exist inside Excel
Macros, add-ins, pivot refreshes and query connections are all reachable, and all use the same pattern: find the object in Excel's model and call the method the VBA editor would have called.
book = excel.Workbooks.Open(r"C:\reports\dashboard.xlsm")
excel.Application.Run("dashboard.xlsm!RebuildSummary", "2026-Q3")
book.RefreshAll() # queries and pivot caches
excel.CalculateUntilAsyncQueriesDone() # wait for them to finish
book.Save()
RefreshAll returns immediately — it starts the refresh rather than completing it — which is why
the wait call matters. Saving without it produces a workbook containing the previous run's numbers,
a failure that is invisible until somebody notices the report has not moved.
Refresh Excel Data Connections and Recalculate with win32com
covers the timing in detail.
Translating VBA into Python
The most useful property of COM automation is that Excel documents it for you. Record a macro, open the VBA editor, and the generated code names the exact objects and methods to call — the translation to Python is nearly mechanical.
' VBA, recorded in Excel
Sheets("Data").Range("A1:D100").Sort Key1:=Range("B1"), Order1:=xlAscending, Header:=xlYes
# The same call from Python
XL_ASCENDING, XL_YES = 1, 1
sheet = book.Sheets("Data")
sheet.Range("A1:D100").Sort(
Key1=sheet.Range("B1"), Order1=XL_ASCENDING, Header=XL_YES
)
Three rules cover most of it: .Method arg becomes .Method(arg), Set x = y becomes x = y, and
VBA's named constants (xlAscending, xlYes) become integers — available as
win32com.client.constants.xlAscending once EnsureDispatch has built the type cache.
The process-lifetime problem
Every other library on this site releases its resources when the object goes out of scope. COM does
not: Excel stays alive while any reference to it exists, including references held by a traceback in
an interactive session. An orphaned EXCEL.EXE holds a lock on the workbook it had open, and after
a few scheduled runs the machine has several of them.
import pythoncom
import win32com.client as win32
def with_excel(work):
pythoncom.CoInitialize()
excel = win32.gencache.EnsureDispatch("Excel.Application")
excel.Visible = False
excel.DisplayAlerts = False
try:
return work(excel)
finally:
excel.Quit()
del excel
pythoncom.CoUninitialize()
CoInitialize is required in any thread that is not the main one — a Flask request handler, a
worker thread, an APScheduler job — and its absence produces a confusing "CoInitialize has not been
called" error rather than anything about threads.
Close Excel Cleanly and Avoid Orphan COM Processes
covers the full lifecycle, including detecting the orphans you already have.
When to use this instead of xlwings
xlwings wraps this same interface and hides most of the sharp edges: it converts ranges to and from DataFrames, quits the application through a context manager, and gives objects that behave like Python. For most work it is simply the better ergonomics on the same capability.
Raw pywin32 earns its place in three situations. When you are translating an existing VBA macro
almost line for line, matching the object model exactly is easier than mapping it onto a wrapper.
When you need a corner of Excel that xlwings has not wrapped — mail merges, some chart properties,
Application-level settings — you end up on .api anyway, which is a pywin32 object. And when the
dependency footprint matters, pywin32 is one package rather than two.
Attaching to a workbook that is already open
Three different calls produce an Application object, and picking the wrong one is how a script
closes the spreadsheet somebody was working in.
GetActiveObject attaches to a copy of Excel that is already running and raises if there is none —
which is what you want when the point is to act on the user's open workbook, including unsaved
changes that are not on disk yet. Dispatch reuses a running instance if one exists and starts one
otherwise, which is convenient and occasionally surprising. DispatchEx always starts a private
instance, which is the safe choice for anything automated because quitting it cannot disturb a
person's session.
import win32com.client as win32
try:
excel = win32.GetActiveObject("Excel.Application") # act on what the user has open
started_it = False
except Exception:
excel = win32.DispatchEx("Excel.Application") # private instance, safe to quit
started_it = True
try:
book = next((b for b in excel.Workbooks if b.Name == "budget.xlsx"), None)
if book is None:
book = excel.Workbooks.Open(r"C:\finance\budget.xlsx")
print(book.Sheets("Summary").Range("C12").Value)
finally:
if started_it:
excel.Quit()
The started_it flag is the part worth copying. Quitting an application you did not start closes
every workbook the user had open, without asking — a one-line bug with a memorable cost.
Formatting and printing through Excel
Everything Excel can do to a sheet's appearance is reachable, and two of those things have no good equivalent in the file-level libraries: printing with Excel's own pagination, and exporting a PDF that matches it exactly.
XL_LANDSCAPE, XL_TYPE_PDF = 2, 0
sheet = book.Sheets("Summary")
setup = sheet.PageSetup
setup.Orientation = XL_LANDSCAPE
setup.Zoom = False
setup.FitToPagesWide = 1
setup.FitToPagesTall = False
setup.PrintArea = "A1:H60"
setup.CenterFooter = "Page &P of &N"
setup.LeftHeader = "&D Regional revenue"
book.ExportAsFixedFormat(XL_TYPE_PDF, r"C:\reports\summary.pdf")
Zoom = False is required before FitToPagesWide has any effect — Excel treats zoom and fit-to-page
as mutually exclusive, and setting the second while the first is active silently does nothing. The
same page-setup properties are available from openpyxl for the layout itself, as
Set the Print Area and Page Setup with openpyxl
shows; what openpyxl cannot do is produce the PDF.
Constants, errors and the type cache
Early binding through EnsureDispatch does more than speed calls up: it generates a Python module
describing Excel's type library, which is what makes named constants and useful error messages
available.
import win32com.client as win32
from win32com.client import constants
excel = win32.gencache.EnsureDispatch("Excel.Application")
sheet = excel.Workbooks.Open(r"C:\data\orders.xlsx").Sheets(1)
sheet.Range("A1:D500").Sort(
Key1=sheet.Range("B1"),
Order1=constants.xlAscending,
Header=constants.xlYes,
)
Without the cache, constants.xlAscending raises and you are back to remembering that ascending is
1. The cache is written under the user's temporary directory the first time and reused after that;
if Excel is upgraded and the cached module goes stale, deleting the gen_py folder regenerates it —
which is the fix for the otherwise baffling "This COM object can not automate the makepy process"
error.
COM exceptions themselves arrive as pywintypes.com_error with a tuple of numbers rather than a
message. The second element is usually the readable part, and the fourth carries Excel's own
description when there is one — decoding it turns an opaque failure into something loggable, which
Handle COM Errors and Excel Dialog Prompts in Python
covers in full.
Charts, shapes and images
Chart objects are one of the places where the file-level libraries and Excel disagree most: openpyxl can create a chart but not render one, and it drops several chart types entirely on a round trip. Through COM you get Excel's own chart engine, including the ability to export a chart as an image for use somewhere else.
XL_LINE, XL_COLUMN_CLUSTERED = 4, 51
sheet = book.Sheets("Data")
chart_object = sheet.ChartObjects().Add(Left=420, Top=20, Width=460, Height=280)
chart = chart_object.Chart
chart.ChartType = XL_LINE
chart.SetSourceData(sheet.Range("A1:B37"))
chart.HasTitle = True
chart.ChartTitle.Text = "Monthly revenue"
chart.Export(r"C:\reports\revenue.png") # a real rendered image
Chart.Export has no equivalent anywhere in the pure-Python ecosystem — it is Excel drawing the
chart and saving the pixels. That makes it the practical route when a chart has to appear in an
email body or a slide rather than in a workbook, alongside the matplotlib approach in
Embed a Matplotlib Chart in an Excel Report.
The same object model covers shapes, text boxes and images, and the naming follows Excel's own:
sheet.Shapes.AddPicture(...), sheet.Shapes("Logo").Delete(). Anything you can do by hand and
record as a macro is available here under exactly the name the recorder produced.
Where this should not run
It is worth being blunt about deployment, because the failure mode is expensive. Microsoft does not support Office automation from a service, a scheduled task under a non-interactive account, or a web server process. Excel expects a desktop session: without one it may work for weeks and then hang on a first-run dialog, a licence prompt or a repair message that no one can dismiss, and the symptom is a job that stops producing output without producing an error either.
The workable arrangements are an interactive workstation that a person logs into, or a dedicated Windows machine with a logged-in session and the script driven by a task that runs in that session. Anything else — a container, a Linux runner, a cloud function — belongs to openpyxl and xlsxwriter, which is the argument set out in Run a Python Excel Report in Docker.
A useful design rule follows from that: keep the COM-dependent step as small and as late as possible. Build the workbook with file-level libraries, and use Excel only for the one operation that needs it — the recalculation, the macro, the PDF — so that when the automation host eventually becomes a problem, the part that has to move is a dozen lines rather than the whole job.
A worked example: refresh, recalculate, export
Putting the pieces together, a realistic COM job is short. It opens a model somebody else maintains, lets Excel do the two things only Excel can do, and hands the result to the rest of the pipeline as an ordinary file.
import pythoncom
import win32com.client as win32
def publish(model_path: str, pdf_path: str) -> None:
pythoncom.CoInitialize()
excel = win32.DispatchEx("Excel.Application")
excel.Visible = False
excel.DisplayAlerts = False
excel.AskToUpdateLinks = False
try:
book = excel.Workbooks.Open(model_path, UpdateLinks=0, ReadOnly=False)
try:
book.RefreshAll()
excel.CalculateUntilAsyncQueriesDone()
book.Save()
book.Sheets("Board summary").ExportAsFixedFormat(0, pdf_path)
finally:
book.Close(SaveChanges=False)
finally:
excel.Quit()
pythoncom.CoUninitialize()
publish(r"C:\finance\model.xlsm", r"C:\reports\board-2026-Q3.pdf")
AskToUpdateLinks = False and UpdateLinks=0 together suppress the prompt a workbook with external
references raises on open — another dialog that hangs an unattended run. The nested try blocks are
deliberate: the workbook closes even if the export fails, and the application quits even if the
close fails, which is what keeps the process count at zero after a bad night.
Everything after this point — attaching the PDF to an email, uploading it, logging the run — is ordinary Python that no longer needs Excel at all. That boundary is where a COM script should end.
Key takeaways
- COM automation drives a running copy of Excel; it is Windows-only, needs a licensed installation, and has no headless mode.
- Cost is per call, not per cell — move whole ranges in one assignment and a slow script becomes a fast one.
DisplayAlerts = Falseprevents the invisible modal dialog that turns a failure into a hang.RefreshAllstarts a refresh rather than finishing it; wait for queries before saving.- Excel only exits when every reference is released, so quit in a
finallyand initialise COM explicitly in any non-main thread. - Recorded VBA is the documentation: the object model is identical, and the translation to Python is close to mechanical.
Frequently asked questions
What is the difference between pywin32 and xlwings? xlwings is a friendly wrapper around the same COM interface pywin32 exposes. xlwings gives you Python-shaped objects, DataFrame conversion and a context manager; pywin32 gives you Excel's object model exactly as Microsoft documents it. Anything xlwings has not wrapped is reachable through its .api attribute, which is a pywin32 object.
Does COM automation work on macOS or Linux? No. COM is a Windows technology. On macOS xlwings uses AppleScript instead, and on Linux there is no application to drive at all — use openpyxl or xlsxwriter there.
Why does Excel stay running after my script ends? Because a COM reference is still held somewhere, or the script exited before Quit() was called. Excel only closes when every reference to it is released, which is why the pattern in this section always quits in a finally block.
Can I run COM automation from a scheduled task or a service? It is possible and unsupported. Excel expects an interactive desktop session; under a service account it frequently hangs on a dialog nobody can dismiss. For anything scheduled, use the file-level libraries and keep COM for interactive machines.
How do I find the right method name? The COM object model is the same one the VBA editor documents, so record a macro in Excel, read the generated VBA, and translate it almost line for line. Method and property names are identical; only the syntax changes.
Do I need Excel installed for pywin32 itself? pywin32 installs fine without Excel, but the Excel.Application dispatch will fail at runtime with a class-not-registered error. The library is the bridge; Excel is what it bridges to.
Related
- Up one level: Getting Started with Python Excel Automation — where application automation sits among the file-level libraries.
- Read and Write Cell Ranges with win32com — moving blocks of data across the boundary efficiently.
- Run an Excel Macro from Python with win32com — calling existing VBA, with arguments and return values.
- Refresh Excel Data Connections and Recalculate with win32com — queries, pivot caches and the wait that stops a stale save.
- Close Excel Cleanly and Avoid Orphan COM Processes — the lifecycle rules that keep EXCEL.EXE from accumulating.
- Handle COM Errors and Excel Dialog Prompts in Python — decoding COM exceptions and stopping the invisible-dialog hang.
- xlwings vs pywin32 for Excel Automation — the wrapper against the raw interface, feature by feature.
- Automating Excel with xlwings Basics — the friendlier API over the same capability.