Home Tutorials Standard Library

Standard Library

Python's zipfile Module: Reading and Writing Zip Archives

Pyford Notes July 6, 2026 7 min read
Key points
  • ZipFile('archive.zip', 'w') creates a new archive; write() adds files to it and writestr() adds data directly without a source file on disk.
  • read() and open() let you pull a member's bytes into memory or get a file-like handle without extracting anything to disk first.
  • infolist() and getinfo() expose per-file metadata -- compressed size, original size, modification time -- without reading the file's actual content.
  • extractall() on an untrusted zip can write outside the target directory via '../' path traversal in filenames -- validate member names first.

Creating an archive with ZipFile

zipfile.ZipFile works like a normal file object opened in a mode — "r" to read, "w" to create or overwrite, "a" to append — and is best used as a context manager so it always closes properly:

import zipfile

with zipfile.ZipFile("report_bundle.zip", "w", zipfile.ZIP_DEFLATED) as zf:
    zf.write("report.pdf")                       # adds the file, keeping its name
    zf.write("data/summary.csv", arcname="summary.csv")  # renamed inside the archive
    zf.writestr("metadata.json", '{"version": 2}')          # write data with no source file

arcname controls the path stored inside the archive, independent of the path on disk — useful for stripping a long local directory structure down to just the filename, or organising files into a different layout inside the zip than they have on disk. writestr() is the direct equivalent for data that only exists in memory, like a generated report or JSON payload, with no need to write a temporary file first.

Reading a member without extracting it to disk

A common need is reading one file out of an archive without unpacking the whole thing to a temp directory:

import zipfile

with zipfile.ZipFile("report_bundle.zip") as zf:
    data = zf.read("summary.csv")            # returns bytes, entirely in memory
    print(data.decode("utf-8"))

    with zf.open("metadata.json") as f:        # a file-like handle, for streaming reads
        content = f.read()

read() loads the whole member into memory as bytes, which is fine for small files; open() returns a file-like object supporting .read(chunk_size), better suited to large members you'd rather not hold entirely in memory at once.

Listing contents and per-file metadata

namelist() gives just the filenames; infolist() and getinfo(name) return ZipInfo objects carrying per-entry metadata, letting you inspect an archive's contents without decompressing anything:

import zipfile

with zipfile.ZipFile("report_bundle.zip") as zf:
    print(zf.namelist())    # ['report.pdf', 'summary.csv', 'metadata.json']

    for info in zf.infolist():
        ratio = info.compress_size / info.file_size if info.file_size else 0
        print(f"{info.filename}: {info.file_size} bytes -> {info.compress_size} bytes ({ratio:.0%})")

This metadata pass is cheap because zip format stores a central directory at the end of the file listing every member and its offsets — reading it doesn't require decompressing any file content, which is why archive managers can display a file listing instantly even for a very large zip.

Compression methods and why some files barely shrink

ZIP_STORED (no compression), ZIP_DEFLATED (the common default, using the same algorithm as gzip), ZIP_BZIP2, and ZIP_LZMA are the available methods, passed when opening the archive or per-file via write()'s compress_type argument:

import zipfile

with zipfile.ZipFile("archive.zip", "w") as zf:
    zf.write("document.txt", compress_type=zipfile.ZIP_DEFLATED)   # compresses well
    zf.write("photo.jpg", compress_type=zipfile.ZIP_STORED)          # already compressed, skip re-compressing

Files that are already compressed — JPEG, MP4, most modern document formats, and anything already gzipped — barely shrink further under DEFLATE and sometimes come out very slightly larger due to the zip format's own per-entry overhead; storing them uncompressed with ZIP_STORED avoids wasting CPU time for no space benefit.

Extraction safety: the zip-slip trap

A zip archive's filenames are just strings, and nothing in the format itself prevents a malicious archive from containing an entry named ../../etc/cron.d/malicious. Calling extractall() on an untrusted archive without checking member names first can write files outside the intended destination directory — a well-known vulnerability class called zip-slip:

import zipfile, os

def safe_extract(zip_path, dest_dir):
    dest_dir = os.path.abspath(dest_dir)
    with zipfile.ZipFile(zip_path) as zf:
        for member in zf.namelist():
            target = os.path.abspath(os.path.join(dest_dir, member))
            if not target.startswith(dest_dir + os.sep):
                raise ValueError(f"unsafe path in archive: {member!r}")
        zf.extractall(dest_dir)   # only reached if every member passed the check above

Python 3.12 added a filter= argument to extractall() and extract() (mirroring the same addition in tarfile) that rejects unsafe paths automatically when set to "data"; for archives from any source you don't fully control, either that filter or an explicit check like the one above should run before extraction. Full API coverage is in the zipfile documentation.