How-to
Getting Started With Python Excel AutomationDeep dive

Read Excel Files Inside a ZIP Archive with Python

Read workbooks straight out of a .zip without unpacking it: list the members, open one with zipfile and pass it to pd.read_excel, loop over every workbook while skipping __MACOSX and lock-file junk, read archives downloaded into memory, handle password-protected ZIPs, and understand the errors pandas gives when handed the archive itself.

Monthly exports often arrive as a ZIP: a download from a finance system, an email attachment that bundles twelve branch workbooks, a vendor's nightly drop on an SFTP server. The usual reflex is to unzip into a temporary folder and read the files from disk, then remember to clean up. Python's standard zipfile module makes that unnecessary — each member of the archive can be opened as a file object and handed straight to pd.read_excel, so nothing touches the disk and there is nothing to delete afterwards. This page shows the pattern, the junk entries that break naive loops, archives held in memory, passwords, and the confusing error you get if you pass the .zip to pandas directly. It is part of Reading Excel Files with Pandas.

Prerequisites

Bash
pip install pandas openpyxl

zipfile ships with Python. The example archive, monthly_sales.zip, contains three monthly workbooks in a 2026/ folder — plus the debris real archives collect: a __MACOSX resource-fork entry added by the macOS "Compress" command, an Excel owner-lock file (~$sales_feb.xlsx) zipped up because the workbook was open at the time, and a README.

Step 1 — List what is inside

Python
import zipfile

with zipfile.ZipFile("monthly_sales.zip") as z:
    for info in z.infolist():
        print(f"{info.filename:35} {info.file_size:>7} bytes")

infolist() returns one ZipInfo per member with its path inside the archive, its uncompressed size and its modification time. Paths always use forward slashes, whatever system created the archive. Listing first is worth the two lines: it shows the folder structure you have to match and the junk you will have to skip.

Step 2 — Read one workbook without extracting it

Python
import pandas as pd

with zipfile.ZipFile("monthly_sales.zip") as z:
    with z.open("2026/sales_jan.xlsx") as fh:
        jan = pd.read_excel(fh)
print(jan)

z.open() returns a ZipExtFile, a read-only, seekable file object that decompresses on demand. read_excel accepts any binary file object, so it reads the workbook exactly as if it were on disk. Keep the read inside the with blocks: once the archive is closed, the member's file object can no longer be read.

From archive member to DataFrame without touching the disk The ZIP archive is opened with zipfile.ZipFile, one member is opened as a file object with z.open, and that object is passed to pd.read_excel, which returns a DataFrame. No temporary folder is involved. monthly_sales.zip the archive on disk or in memory zipfile.ZipFile reads the central directory z.open(name) seekable file object pd.read_excel DataFrame

Step 3 — Read every workbook and skip the junk

A loop over namelist() that reads everything ending in .xlsx trips over both junk entries: the __MACOSX/._sales_jan.xlsx file and the ~$ lock file have the right extension but are not workbooks, and read_excel fails on them with ValueError: Excel file format cannot be determined. Filter on the file name, not just the extension:

Python
from pathlib import PurePosixPath

def workbook_members(z):
    for info in z.infolist():
        path = PurePosixPath(info.filename)
        if info.is_dir() or path.parts[0] == "__MACOSX":
            continue
        if path.name.startswith(("~$", "._")) or path.suffix.lower() not in {".xlsx", ".xlsm", ".xls"}:
            continue
        yield info

frames = []
with zipfile.ZipFile("monthly_sales.zip") as z:
    for info in workbook_members(z):
        with z.open(info) as fh:
            df = pd.read_excel(fh)
        df.insert(0, "Source", PurePosixPath(info.filename).name)
        frames.append(df)

sales = pd.concat(frames, ignore_index=True)
print(sales)

PurePosixPath handles the forward-slash paths regardless of the operating system the script runs on. Adding a Source column before concatenating keeps every row traceable to its file, the same habit recommended in Combine Multiple Excel Files into One with Python. The suffix check is case-insensitive because Windows users produce REPORT.XLSX as often as report.xlsx.

Which archive members the filter keeps Members with an xlsx extension that are real workbooks are kept. The __MACOSX resource fork, the Excel owner lock file and the README text file are skipped. Read 2026/sales_jan.xlsx 2026/sales_feb.xlsx 2026/sales_mar.xlsx Skip __MACOSX/2026/._sales_jan.xlsx (macOS metadata) 2026/~$sales_feb.xlsx (owner lock file) 2026/README.txt (not a workbook)

Step 4 — Read an archive that only exists in memory

Archives downloaded over HTTP or fetched from object storage do not need to be saved first. ZipFile accepts any seekable binary file object, so wrap the bytes in io.BytesIO:

Python
import io

from pathlib import Path

payload = Path("monthly_sales.zip").read_bytes()   # stands in for requests.get(url).content
with zipfile.ZipFile(io.BytesIO(payload)) as z:
    names = [i.filename for i in workbook_members(z)]
    feb = pd.read_excel(z.open(names[1]))
print(names)
print(feb)

In a real job payload would be requests.get(url, timeout=60).content or the body of an S3 object; the rest is identical. The approach mirrors Read an Excel File from a URL or Bytes in Python with one more layer of wrapping. The whole archive sits in memory, which is fine for the tens of megabytes typical of report bundles.

Step 5 — Password-protected archives

zipfile can decrypt the classic ZIP password scheme (often called ZipCrypto) that zip -P and most "add password" dialogs produce. Pass the password as bytes:

Python
import subprocess

subprocess.run(["zip", "-q", "-P", "s3cret", "locked.zip", "monthly_sales.zip"], check=True)
with zipfile.ZipFile("locked.zip") as z:
    info = z.infolist()[0]
    print("encrypted:", bool(info.flag_bits & 0x1))
    try:
        z.read(info.filename)
    except RuntimeError as exc:
        print("RuntimeError:", exc)
    inner = io.BytesIO(z.read(info.filename, pwd=b"s3cret"))
print(zipfile.ZipFile(inner).namelist()[:3])

Without the password, reading raises RuntimeError: File 'monthly_sales.zip' is encrypted, password required for extraction; with it, the bytes come back and can be opened as before (here the protected member is itself a ZIP, which nests just as easily). Archives encrypted with AES — 7-Zip's and WinZip's stronger option — raise NotImplementedError: That compression method is not supported, because the standard library does not implement AES. Use the third-party pyzipper package, which offers the same interface, or ask the sender for ZipCrypto. Keep the password out of the script, as described in Store Credentials for Excel Report Scripts Safely.

Step 6 — Do not hand the ZIP to pandas

An .xlsx workbook is itself a ZIP file of XML parts — zipfile.is_zipfile("sales.xlsx") returns True. pandas therefore looks inside any ZIP it is given to decide what kind of workbook it is. A report bundle has none of the parts it looks for, and the error is baffling:

Python
try:
    pd.read_excel("monthly_sales.zip")
except Exception as exc:
    print(type(exc).__name__, exc)

pandas 3 raises OptionError: No such keys(s): 'io.excel.zip.reader' — it fell back to treating the extension zip as a format name and found no reader registered for it. read_excel also has no compression= argument, unlike read_csv, which can read a single CSV out of a ZIP on its own. For workbooks, always open the archive with zipfile first.

An archive of workbooks is a ZIP of ZIPs The outer layer is the report archive monthly_sales.zip. Inside it each member such as sales_jan.xlsx is itself a ZIP package, and inside that are the XML parts such as workbook.xml and sheet1.xml that openpyxl parses. monthly_sales.zip opened with zipfile.ZipFile 2026/sales_jan.xlsx a ZIP package passed to read_excel xl/workbook.xml, xl/worksheets/sheet1.xml XML parts parsed by the engine

Step 7 — Write the results back into an archive

The reverse trip is just as short. When the output is several workbooks — one per region, say — build each one in memory and add it to a new archive with writestr, again without temporary files:

Python
with zipfile.ZipFile("by_region.zip", "w", zipfile.ZIP_DEFLATED) as out:
    for region, part in sales.groupby("Region"):
        buf = io.BytesIO()
        part.to_excel(buf, index=False)
        out.writestr(f"sales_{region.lower()}.xlsx", buf.getvalue())
print(zipfile.ZipFile("by_region.zip").namelist())

Each BytesIO buffer holds a complete workbook; writestr stores its bytes under the given name. The result is ready to attach to an email, as in Zip Large Excel Attachments Before Emailing, or to upload in one request.

Common pitfalls

  • Junk members. __MACOSX/._* and ~$* entries end in .xlsx but are not workbooks; filter them by name.
  • Reading after the archive closes. A member opened with z.open() is unreadable once the ZipFile is closed; read inside the with block or copy the bytes out with z.read().
  • Backslashes in member names. Some Windows tools write folder\file.xlsx. zipfile keeps such names as given, so a PurePosixPath check sees one long name — normalise with info.filename.replace("\\", "/") when matching.
  • Non-ASCII file names. Archives created without the UTF-8 flag store names in the old DOS code page and come back garbled. Python 3.11+ accepts ZipFile(path, metadata_encoding="cp437") or the sender's code page to decode them.
  • Expecting compression savings. Zipping workbooks barely shrinks them, because each .xlsx is already compressed; a 2.4 MB workbook became 2.39 MB in the archive during testing.

Performance notes

Reading from a ZipExtFile costs about the same as reading from disk: on a 60,000-row workbook, streaming it from the archive took 1.7 seconds with openpyxl and copying it into io.BytesIO first took 1.5 seconds — both dominated by XML parsing. Switching the engine matters far more: engine="calamine" read the same member in 0.23 seconds. For archives with dozens of large workbooks, read members in parallel processes, each opening the archive itself; a ZipFile object should not be shared between processes.

Conclusion

Open the archive with zipfile.ZipFile, filter infolist() down to real workbooks by name, and pass each member's file object from z.open() to pd.read_excel — no extraction, no temporary folder. Wrap downloaded bytes in io.BytesIO, pass pwd= for classic password ZIPs and switch to pyzipper for AES, and never hand the archive itself to pandas, which mistakes it for a workbook and fails with an io.excel.zip.reader error.

Frequently asked questions

Can pandas read an Excel file inside a ZIP without extracting it? Yes. Open the archive with zipfile.ZipFile, open the member with z.open("folder/file.xlsx") and pass that file object to pd.read_excel. Nothing is written to disk.

Why does pd.read_excel on a .zip file fail with "No such keys(s): io.excel.zip.reader"? pandas treats any ZIP as a possible workbook package, finds none of the parts it expects, and falls back to using the extension zip as a reader name, for which no reader exists. Open the archive with zipfile and read the members instead.

How do I read every Excel file in a ZIP into one DataFrame? Loop over z.infolist(), skip directories, __MACOSX entries and names starting with ~$ or ._, read each remaining .xlsx with pd.read_excel(z.open(info)), add a column with the member name, and pd.concat the results.

Can Python open a password-protected ZIP of Excel files? The standard zipfile module decrypts classic ZipCrypto archives when you pass pwd=b"password". AES-encrypted archives raise NotImplementedError; use the pyzipper package for those.

More on reading excel files

More on reading excel files →