BadZipFile: File is not a zip file in Python (tested)
zipfile.BadZipFile: File is not a zip file is the same message for four different problems. We reproduced each: an empty file, an HTML error page saved under a .zip name, a truncated download, and a non-zip file with a zip-type extension (like a CSV named .xlsx). The truncated case is the confusing one -- it starts with the zip signature PK and still fails, because zipfile reads the archive's directory from the end of the file.
1. Every common cause gives the same message
import io, os, tempfile, zipfile
d = tempfile.mkdtemp()
good = os.path.join(d, "good.zip")
with zipfile.ZipFile(good, "w") as z:
z.writestr("a.txt", "hello " * 1000)
data = open(good, "rb").read()
cases = {
"empty file": b"",
"HTML page saved as .zip": b"<!DOCTYPE html><html><body>404 Not Found</body></html>",
"truncated download (first 60%)": data[: int(len(data) * 0.6)],
"CSV renamed to .xlsx": b"name,qty\nwidget,3\n",
"valid zip": data,
}
for label, blob in cases.items():
p = os.path.join(d, "f.zip")
open(p, "wb").write(blob)
try:
with zipfile.ZipFile(p) as z:
print(f"{label:32} OK, members: {z.namelist()}")
except zipfile.BadZipFile as e:
print(f"{label:32} BadZipFile: {e} | is_zipfile: {zipfile.is_zipfile(p)} | starts: {blob[:4]!r}")
# A BytesIO you wrote into is fine: ZipFile seeks itself
buf = io.BytesIO(data)
buf.seek(0, 2)
print("BytesIO positioned at end ", zipfile.ZipFile(buf).namelist())
empty file BadZipFile: File is not a zip file | is_zipfile: False | starts: b''
HTML page saved as .zip BadZipFile: File is not a zip file | is_zipfile: False | starts: b'<!DO'
truncated download (first 60%) BadZipFile: File is not a zip file | is_zipfile: False | starts: b'PK\x03\x04'
CSV renamed to .xlsx BadZipFile: File is not a zip file | is_zipfile: False | starts: b'name'
valid zip OK, members: ['a.txt']
BytesIO positioned at end ['a.txt']
Measured: an empty file, an HTML page saved under a .zip name, a truncated download, and a CSV renamed to .xlsx all raise the identical BadZipFile: File is not a zip file. The first four bytes tell them apart: a real zip starts with PK\x03\x04.
The surprise is the truncated download: it starts like a valid zip, yet zipfile still says it is "not a zip file". zipfile locates the archive by reading the end of central directory record at the end of the file; when the end is missing, it cannot find it and gives the same message as for a file that was never a zip. A BytesIO left positioned at the end is not a cause -- ZipFile seeks on its own.
2. Find out which one you have
with open(path, "rb") as f:
head = f.read(64)
print(os.path.getsize(path), head)
0bytes: the download or write failed, or you are reading the file before the writer has finished.- Starts with
<!DOCTYPEor<html: you saved an error, login or redirect page. With requests, checkr.status_codeandr.headers["Content-Type"]before writing, and callr.raise_for_status(). - Starts with
PK\x03\x04but fails: truncated. Compare the size with the server'sContent-Length, and re-download; see our IncompleteRead guide for connections cut mid-body. - Readable text like
name,qty: it is a CSV (or other format) with a.zip/.xlsx/.docxextension. Read it with the right reader, e.g.pandas.read_csv.
3. Guard before opening
if not zipfile.is_zipfile(path):
raise ValueError(f"{path} is not a complete zip ({os.path.getsize(path)} bytes)")
zipfile.is_zipfile() returned False for all four bad inputs above, including the truncated one, so it is a cheap check before handing the file to anything that expects a zip-based format (.xlsx, .docx, .jar, .whl, .apk are all zip archives).
Frequently asked questions
Why does a file starting with PK still raise BadZipFile?
zipfile finds the archive through the end-of-central-directory record at the end of the file. We measured a 60% truncated copy of a valid zip: it starts with PK\x03\x04 but raises 'File is not a zip file' because the end is missing.
I get it when opening an .xlsx with pandas or openpyxl. Why?
.xlsx files are zip archives, so the same causes apply: the file is empty, is really a CSV or HTML with an .xlsx name, or was cut off. Check the first bytes as shown; a real .xlsx starts with PK.
How do I check if a file is a valid zip?
zipfile.is_zipfile(path). We measured it returning False for the empty, HTML, truncated and CSV cases and True only for the complete archive.
Can I recover a truncated zip?
Not with zipfile's normal reader, since the central directory at the end is gone. Re-download it; tools like zip -FF can sometimes salvage entries but the missing data is still missing.
Could a file position issue cause it?
Not for zipfile: we measured ZipFile opening a BytesIO positioned at the end without problems, because it seeks itself.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in zipfile module, opening an empty file, an HTML page, a truncated (60%) copy of a valid zip, a CSV with a zip-type name, and a valid zip. PlainHeaders is our extension; Python's zipfile is built in.