Read and Write Cell Ranges with win32com
Everything a COM script does eventually comes down to moving values between Python and a worksheet, and the difference between a script that takes two seconds and one that takes four minutes is entirely in how those moves are batched. This guide, part of Automating Excel with COM and pywin32, covers reading and writing blocks, the shape Excel expects, and the conversions that happen at the boundary.
Prerequisites
pip install pywin32
The snippets below assume an open workbook and a sheet object obtained the usual way:
import win32com.client as win32
excel = win32.DispatchEx("Excel.Application")
excel.Visible = False
excel.DisplayAlerts = False
book = excel.Workbooks.Open(r"C:\data\orders.xlsx")
sheet = book.Sheets("Data")
Reading a block in one call
Range.Value on a multi-cell range returns a tuple of row tuples — Excel's own array, converted
once at the boundary.
rows = sheet.Range("A2:D5001").Value
print(type(rows), len(rows), rows[0])
# <class 'tuple'> 5000 ('1001', 'A-100', 3, 19.99)
header = sheet.Range("A1:D1").Value[0]
frame_ready = [dict(zip(header, row)) for row in rows]
A single cell is different: sheet.Range("B2").Value returns the scalar, not a nested tuple. Code
that has to handle both shapes is code that will eventually be wrong, so it is worth normalising
once:
def read_block(sheet, address):
value = sheet.Range(address).Value
if not isinstance(value, tuple):
return ((value,),)
if not isinstance(value[0], tuple):
return (value,)
return value
Writing a block in one call
The write direction is stricter. Excel wants a sequence of row sequences, and a flat list is read as
a single row — which is why assigning [1, 2, 3] to A1:A3 puts 1 in A1 and nothing else.
values = [12400.0, 9800.5, 15320.25]
sheet.Range("B2:B4").Value = [[v] for v in values] # a column
sheet.Range("B2:D2").Value = [values] # a row
sheet.Range("B2:D3").Value = [values, values] # a block
The range size and the data size have to agree. A block smaller than the range leaves the remainder untouched; a block larger than the range is silently truncated, which is the more dangerous of the two because nothing complains.
Finding the extent of the data
UsedRange is Excel's own answer and it is often too generous — deleting rows without clearing
their formatting leaves them inside it. The End navigation that a person performs with Ctrl+Arrow
is more reliable:
XL_UP, XL_TO_LEFT = -4162, -4159
last_row = sheet.Cells(sheet.Rows.Count, 1).End(XL_UP).Row
last_col = sheet.Cells(1, sheet.Columns.Count).End(XL_TO_LEFT).Column
print(f"data occupies {last_row} rows and {last_col} columns")
block = sheet.Range(sheet.Cells(2, 1), sheet.Cells(last_row, last_col)).Value
Building a range from two Cells calls is the programmatic equivalent of A2:D5001, and it avoids
constructing address strings with column letters — worth it as soon as the column count is dynamic.
Types at the boundary
Conversions happen automatically and mostly do what you want. Numbers arrive as float even when
the cell shows an integer, dates arrive as datetime objects with a timezone attached by pywin32,
empty cells arrive as None, and error cells arrive as integers that look nothing like the #N/A
on screen.
from datetime import datetime
raw = sheet.Range("A2:C3").Value2 # Value2 skips date and currency conversion
converted = sheet.Range("A2:C3").Value
print(raw[0][0]) # 45658.0 — the Excel serial number
print(converted[0][0]) # datetime object
Value2 is worth knowing about on large blocks: skipping the date conversion for tens of thousands
of cells is a measurable saving, and converting serials yourself is a two-line job covered in
Fix Excel Serial Numbers Showing Instead of Dates.
Into and out of a DataFrame
pywin32 has no DataFrame support of its own, but the conversion is three lines in each direction and lets the rest of a pipeline use the tools it already has.
import pandas as pd
header = sheet.Range("A1:D1").Value[0]
body = sheet.Range(sheet.Cells(2, 1), sheet.Cells(last_row, 4)).Value
frame = pd.DataFrame(list(body), columns=list(header))
summary = frame.groupby("Region", as_index=False)["Revenue"].sum()
target = book.Sheets("Summary")
target.Range("A1:B1").Value = [list(summary.columns)]
target.Range(target.Cells(2, 1), target.Cells(len(summary) + 1, 2)).Value = summary.values.tolist()
summary.values.tolist() is the important detail: NumPy scalar types do not marshal across COM, and
passing the array directly raises a type error that names neither NumPy nor Excel clearly.
Formatting a range without looping
Formatting is subject to exactly the same call-cost rule as values, and it is where scripts most often regress into a per-cell loop. Almost every visual property is settable on a whole range at once, which means a formatted report costs a handful of calls rather than one per cell.
XL_CENTER, XL_EDGE_BOTTOM, XL_THIN = -4108, 9, 2
header = sheet.Range("A1:D1")
header.Font.Bold = True
header.Font.Size = 11
header.Interior.Color = 0xF0F0F0 # BGR, not RGB
header.HorizontalAlignment = XL_CENTER
header.Borders(XL_EDGE_BOTTOM).Weight = XL_THIN
sheet.Range("D2:D5001").NumberFormat = "#,##0.00"
sheet.Range("A:D").EntireColumn.AutoFit()
sheet.Range("A2").Select()
excel.ActiveWindow.FreezePanes = True
The colour ordering catches everyone once: Excel's COM interface takes colours as BGR integers, so
0xFF0000 is blue rather than red. Writing them as red | (green << 8) | (blue << 16) makes the
intent explicit and stops the next reader from assuming a typo.
EntireColumn.AutoFit() is one of the few things genuinely easier here than in the file-level
libraries, which have to measure text themselves — the approach described in
Auto-Fit Column Widths When Writing with Pandas.
Excel knows its own font metrics, so it gets the answer right where an estimate does not.
Clearing and inserting without corrupting the sheet
Two operations deserve care because they move data that other formulas point at. ClearContents
removes values but leaves formatting; Clear removes both; Delete removes the cells themselves
and shifts everything below or to the right, which is what breaks references.
XL_SHIFT_UP, XL_SHIFT_DOWN = -4162, -4121
sheet.Range("A2:D5001").ClearContents() # keep the formatting, drop the data
sheet.Range("A2:A11").Insert(XL_SHIFT_DOWN) # push existing rows down
sheet.Range("10:12").Delete(XL_SHIFT_UP) # remove three whole rows
For a report that is regenerated each month, ClearContents on the data block followed by a single
range write is the safest pattern: the formatting, the header, the named ranges and any charts
pointing at the sheet all survive, and only the numbers change. Deleting rows to make room is what
turns a chart's source range into #REF! — the same hazard covered from the openpyxl side in
Insert and Delete Rows and Columns with openpyxl.
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Only the first cell of a column is filled | A flat list was assigned | Wrap each value: [[v] for v in values] |
TypeError on a NumPy array | NumPy scalars do not marshal over COM | Convert with .tolist() first |
| Reading a single cell breaks the loop | Value returns a scalar, not a tuple | Normalise the shape, or always read two or more cells |
UsedRange includes thousands of empty rows | Formatting left behind by deleted rows | Use End(xlUp) from the bottom instead |
| Dates are off by a day | The 1900/1904 date system, or a timezone-aware conversion | Read with Value2 and convert the serial yourself |
| The write is silently truncated | The data block is larger than the target range | Size the range from the data, not the other way round |
Performance and scale
One rule dominates: minimise crossings. A read of 50,000 cells in one call typically takes under a second; the same cells fetched individually take minutes. When a script must touch cells selectively, read the whole block once, do the selection in Python, and write back the whole block — even though that sounds wasteful, it is dramatically faster than the targeted alternative.
Two application settings help when writing large blocks into a sheet with formulas:
XL_MANUAL, XL_AUTOMATIC = -4135, -4105
excel.ScreenUpdating = False
excel.Calculation = XL_MANUAL
try:
sheet.Range(sheet.Cells(2, 1), sheet.Cells(50001, 8)).Value = big_block
finally:
excel.Calculation = XL_AUTOMATIC
excel.ScreenUpdating = True
Without them, Excel recalculates and repaints after every assignment. Restoring both in a finally
matters — leaving calculation on manual in an application the user later attaches to is a bug that
will be reported as "my spreadsheet stopped working".
Conclusion
Treat every property access as an expensive network call and the performance problem disappears.
Read blocks with Range.Value, write them as sequences of row sequences, size ranges from the data
using End(xlUp) rather than trusting UsedRange, and convert to and from a DataFrame at the edges
with .tolist(). With screen updating and calculation suspended around a bulk write, a COM script
handles tens of thousands of rows comfortably.
Frequently asked questions
What does Range.Value return for a single cell? A scalar — a float, string, bool or datetime — not a one-element tuple. For a multi-cell range it returns a tuple of row tuples. Code that handles both has to check, which is why it is usually simpler to always read a range of at least two cells.
Why does assigning a flat list only fill the first cell? Excel expects a two-dimensional structure. A flat list is interpreted as a single row, so assigning it to a column range fills one cell. Wrap each value in its own list to make it a column.
What is the difference between Value and Value2? Value2 returns dates and currency as raw numbers rather than converting them to Python datetimes and floats, which makes it measurably faster on large blocks. Use Value2 when you plan to convert the serials yourself.
How do I find the used range reliably? sheet.UsedRange gives Excel's own idea of it, which can be too large if rows were deleted without clearing formats. For a dependable last row, use sheet.Cells(sheet.Rows.Count, 1).End(-4162).Row — the xlUp constant.
Related
- Up one level: Automating Excel with COM and pywin32 — the session skeleton and the call-cost model.
- Run an Excel Macro from Python with win32com — staging a block, then letting VBA loop over it.
- Read and Write a Live Excel Workbook with xlwings — the same operations with DataFrame conversion built in.
- Iterate Over Rows and Columns with openpyxl — the file-level equivalent, with no boundary to cross.
- Fix Excel Serial Numbers Showing Instead of Dates — converting what
Value2hands back.