Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Python  Module 8 – Files, paths and data formats

Archives and compression: zip, tar, gzip, zstd

Create and extract zip and tar archives in Python, compress bytes with gzip, bz2, lzma and the new compression.zstd, and unpack untrusted uploads safely.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create and extract zip and tar archives with zipfile, tarfile and shutil
  • Measure what gzip, bz2, lzma and compression.zstd do to data, and choose between them by size and speed
  • Extract archives from untrusted sources without writing outside the destination
  • Limit what an extractor writes by checking the declared sizes and counting the bytes written

Before you start

What you’ll learn · Before you start

What you will learn

  • Create and extract zip and tar archives with zipfile, tarfile and shutil
  • Measure what gzip, bz2, lzma and compression.zstd do to data, and choose between them by size and speed
  • Extract archives from untrusted sources without writing outside the destination
  • Limit what an extractor writes by checking the declared sizes and counting the bytes written

Before you start

On this page

The three text files backed up in this lesson hold 5,135 bytes: zipped they take 568, and as a .tar.zst 287. An upload of 1,208 bytes, on the other hand, can unpack into a million, and a member named ../../settings.ini asks to be written outside the folder you chose. Python’s standard library builds and reads both kinds of archive, zip with zipfile and tar with tarfile, and compresses bytes with gzip, bz2, lzma and, new in Python 3.14, compression.zstd; shutil.make_archive() and shutil.unpack_archive() do the common jobs in one call.

You will build and read both kinds of archive, measure what each compressor does to real data, choose one for your own files, and unpack archives that someone else made without letting them write outside the destination or fill the disk.

An archive names files, a compressor shrinks bytes

The two words are often used as if they meant the same thing. An archive format answers “which files, under which names, with which metadata”; a lossless compression: Compression whose decompress step gives back exactly the original bytes, as gzip, bz2, lzma and zstd do. Text with repeated patterns shrinks a lot; data that is already compressed hardly shrinks at all. format answers “how do I store these bytes in less space and get them back exactly”. A zip does both at once: it compresses every member on its own and ends with a central directory: The index at the end of a zip file that lists every member's name, its sizes and where its data starts, so that a program can read one member without decompressing the others. that lists every name and where its data starts. A tar only archives: it writes each file behind a header, one after the other, keeping Unix details such as permissions and links, and leaves the shrinking to a compressor that wraps the whole stream. That is why tar files have double extensions: .tar.gz (gzip), .tar.bz2, .tar.xz (lzma) and .tar.zst (Zstandard).

A zip compresses each file on its own and ends with a directory; a .tar.gz joins the files into one tar, then compresses it all.notes-backup.zipnotes.tar.gzheader + dal.txt, compressed aloneheader + goa.txt, compressed aloneheader + shimla.txt, compressed alonecentral directory: every name,size and where its data startsone gzip stream around the whole tarheader + dal.txtheader + goa.txtheader + shimla.txtsame files, other layout

The same three files in a zip and in a .tar.gz

Text description of the diagram

The diagram shows two archives that hold the same three files, dal.txt, goa.txt and shimla.txt, in the order the lesson's examples write them, one above the other.

  1. The zip, notes-backup.zip, is a column of four parts. Each of the first three is a small header followed by one file, compressed on its own. The last part is the central directory, which lists every name, its sizes and where its data starts. A program can read the directory at the end and then jump straight to one file.
  2. The .tar.gz, notes.tar.gz, is one gzip stream. Inside it, the tar format puts each file behind its own header, one after the other, with no directory. Because the whole stream is compressed together, reading the last file means decompressing everything before it, but repeated text across files compresses better.

The layout decides what each format is good at. Step through one read of the last of the three files, shimla.txt, from the zip and from the .tar.gz that the next two sections write:

Interactive · message by message Reading one file from a zip and from a .tar.gz Step through one read of notes/trips/shimla.txt, the last of the three files, first from the zip and then from the .tar.gz.
Reading one file from a zip and from a .tar.gz, step 6 of 6. Messages so far: Your program to notes-backup.zip: read the central directory; notes-backup.zip to Your program: 3 names and offsets; notes-backup.zip to Your program: 62 bytes, shimla.txt only; Your program to notes.tar.gz: decompress from byte 0; notes.tar.gz to Your program: dal.txt, goa.txt, shimla.txt.Your programwants trips/shimla.txtnotes-backup.zip568 bytesnotes.tar.gz335 bytesread the central directory3 names and offsets62 bytes, shimla.txt onlydecompress from byte 0dal.txt, goa.txt, shimla.txt335 bytes against the zip's 568

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The zip's directory. A zip ends with its central directory, so the program reads the end of the file first.
  2. Names and offsets. The directory lists the three members, their sizes and where the data of each one starts in the file.
  3. Straight to one member. shimla.txt is the last member, but its offset is known, so the program reads only its 62 compressed bytes and decompresses them into 1,800.
  4. No index in a .tar.gz. A .tar.gz has no directory. The tar stream inside is one gzip stream, and a gzip stream can only be decompressed from its first byte.
  5. Everything before it. To reach shimla.txt the program decompresses and passes every member before it, the folders, dal.txt with its 1,375 bytes and goa.txt with its 1,960.
  6. What tar gains in exchange. In exchange the compressor saw the three files as one stream and reused their repeated words, so notes.tar.gz took 335 bytes where the zip took 568.

A program can open a zip, read its directory and decompress just one member, which is why many document and package formats are zip files underneath. A .tar.gz has no index: to reach the last file you decompress everything before it. In exchange, compressing the whole stream lets the compressor reuse patterns across files, so a folder of similar text files usually comes out smaller, here 335 bytes against 568.

Checkpoint

Check: Archives and compressors

  1. Question 1 of 2 Which of these is a compression format and not an archive format?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: gzip

    An archive format records files under names, with their metadata; a compression format turns a stream of bytes into fewer bytes and back. tar archives, gzip compresses, and zip does both.

  2. Question 2 of 2 A program needs to read one small file from the middle of a large archive many times. Which format suits it best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A zip, because each member is compressed on its own and the central directory says where it starts

    A zip ends with a central directory that lists every member and where its data starts, so a reader can jump to one member and decompress only that one. A .tar.gz is one compressed stream with no index, so reaching a member means decompressing everything before it.

zipfile writes a zip, and compresses only when asked

zipfile.ZipFile opens an archive like a file, with the mode "r" to read, "w" to write a new one, "a" to add to an existing one and "x" to create one only if it does not exist yet. The example makes a small folder of notes, backs it up, lists what the archive holds, checks it and restores it into another folder:

Back up a folder into a zip, then restore it Python · zip_backup.py
import shutil
import zipfile
from pathlib import Path

# A small folder to back up. The example writes its own files, so it runs anywhere.
notes = Path("notes")
files = {
    "trips/goa.txt": "Packing list: sunscreen, sandals, phone charger.\n" * 40,
    "trips/shimla.txt": "Packing list: jacket, gloves, thermos flask.\n" * 40,
    "recipes/dal.txt": "Rinse the lentils, boil them soft, then add the tadka.\n" * 25,
}
for name, text in files.items():
    path = notes / name
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")


def backup(folder, dest):
    """Write every file under folder into dest/<folder name>-backup.zip and return its path."""
    dest.mkdir(parents=True, exist_ok=True)
    archive = dest / f"{folder.name}-backup.zip"
    with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        for path in sorted(folder.rglob("*")):
            if path.is_file():
                # Names inside the archive are relative: notes/trips/goa.txt, never a full path.
                zf.write(path, arcname=path.relative_to(folder.parent).as_posix())
    return archive


archive = backup(notes, Path("backups"))
print(f"wrote {archive.as_posix()} ({archive.stat().st_size:,} bytes)")

with zipfile.ZipFile(archive) as zf:
    for info in zf.infolist():
        print(f"  {info.filename:<22} {info.file_size:>5,} bytes stored as {info.compress_size:>3} bytes")
    print("damaged members:", zf.testzip())  # None: every member's CRC-32 checksum matches
    zf.extractall("restored")

same = all((Path("restored") / "notes" / name).read_bytes() == (notes / name).read_bytes() for name in files)
print("restored copy matches the original:", same)

# The quick route: one call archives a whole folder (zip, tar, gztar, bztar, xztar or zstdtar).
quick = shutil.make_archive("backups/notes-quick", "gztar", root_dir=".", base_dir="notes")
print("make_archive wrote", Path(quick).name)

Output

wrote backups/notes-backup.zip (568 bytes)
  notes/recipes/dal.txt  1,375 bytes stored as  63 bytes
  notes/trips/goa.txt    1,960 bytes stored as  69 bytes
  notes/trips/shimla.txt 1,800 bytes stored as  62 bytes
damaged members: None
restored copy matches the original: True
make_archive wrote notes-quick.tar.gz

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 zip_backup.py

  • compression=zipfile.ZIP_DEFLATED matters. Without it, ZipFile uses ZIP_STORED and writes the members uncompressed. Here DEFLATE, the method every zip tool understands, turned 1,960 bytes of repetitive text into 69. Press Edit, delete the compression argument and run it again: every member is stored at its full size.
  • arcname is the name inside the archive. Without it, write() stores the path you passed in, folders and all. A relative name such as notes/trips/goa.txt keeps the archive portable. Never store absolute paths.
  • infolist() returns a ZipInfo for each member, with filename, file_size (the size once unpacked) and compress_size (the bytes it takes in the archive). namelist() gives the names alone.
  • testzip() reads every member and checks its CRC-32 checksum, returning the name of the first damaged one, or None when all are intact.
  • extractall(path) unpacks everything below path, and extract(name, path) unpacks one member. read(name) returns a member’s bytes without writing a file.

The last lines show the shortcut. shutil.make_archive(base_name, format, root_dir, base_dir) archives a folder in one call and returns the file name it chose, adding the extension for the format: zip, tar, gztar, bztar, xztar or, from Python 3.14, zstdtar. Paths in the archive are relative to root_dir, and base_dir picks the folder inside it to include. Its partner, shutil.unpack_archive(), guesses the format from the extension.

Version note

Python 3.14 adds zipfile.ZIP_ZSTANDARD for zip members compressed with Zstandard. The zipfile documentation warns that some tools, including older Python releases, cannot read the methods beyond DEFLATE, so keep ZIP_DEFLATED for files you share and use Zstandard where you control both ends.

Checkpoint

Check: Writing and reading zips

  1. Question 1 of 2 You create zipfile.ZipFile("out.zip", "w") and write a 1 MB log file into it. How big is that member inside the archive?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: About 1 MB, because the default compression is ZIP_STORED

    The zipfile documentation gives ZIP_STORED as the default. Checking compress_size against file_size in infolist() shows which one you got.

  2. Question 2 of 2 Why does zip_backup.py pass arcname=path.relative_to(folder.parent).as_posix() to write()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: So that each member is stored under a short relative name such as notes/trips/goa.txt

    arcname is the name inside the archive. Store relative names with forward slashes, never absolute paths, so the archive unpacks the same way on any computer.

tarfile writes one stream that a compressor wraps

tarfile.open(name, mode) opens a tar archive. The mode says what to do and how the stream is compressed: "r:*" reads any of them and detects the compression itself, "w:gz" writes a gzip-compressed tar, and "w:bz2", "w:xz" and, since Python 3.14, "w:zst" choose the other compressors. tar.add(path) adds a file or a whole folder, and getmembers() returns a TarInfo for each member, with its name, size, type and permissions. The example archives the same notes in all five modes:

The same folder as a tar in five modes Python · tar_backup.py
import tarfile
from pathlib import Path

# The folder of zip_backup.py: three short text files in two subfolders.
notes = Path("notes")
files = {
    "trips/goa.txt": "Packing list: sunscreen, sandals, phone charger.\n" * 40,
    "trips/shimla.txt": "Packing list: jacket, gloves, thermos flask.\n" * 40,
    "recipes/dal.txt": "Rinse the lentils, boil them soft, then add the tadka.\n" * 25,
}
for name, text in files.items():
    path = notes / name
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")


def same_metadata(info):
    """add() calls this for each member it writes: the same owner, time and permissions on every run."""
    info.uid = info.gid = info.mtime = 0
    info.uname = info.gname = ""
    info.mode = 0o755 if info.isdir() else 0o644
    return info


# One tar archive of the folder in each compression mode.
for mode, suffix in [("w", ".tar"), ("w:gz", ".tar.gz"), ("w:bz2", ".tar.bz2"), ("w:xz", ".tar.xz"), ("w:zst", ".tar.zst")]:
    archive = Path("notes" + suffix)
    with tarfile.open(archive, mode) as tar:
        tar.add(notes, filter=same_metadata)  # the folder and everything in it, in sorted order
    print(f"{mode:<6} {archive.name:<15} {archive.stat().st_size:>6,} bytes")

with tarfile.open("notes.tar.zst", "r:*") as tar:  # "r:*" finds out the compression itself
    for member in tar.getmembers():
        kind = "folder" if member.isdir() else "file"
        print(f"  {member.name:<22} {kind:<6} {member.size:>5,} bytes")

Output

w      notes.tar       10,240 bytes
w:gz   notes.tar.gz       335 bytes
w:bz2  notes.tar.bz2      439 bytes
w:xz   notes.tar.xz       368 bytes
w:zst  notes.tar.zst      287 bytes
  notes                  folder     0 bytes
  notes/recipes          folder     0 bytes
  notes/recipes/dal.txt  file   1,375 bytes
  notes/trips            folder     0 bytes
  notes/trips/goa.txt    file   1,960 bytes
  notes/trips/shimla.txt file   1,800 bytes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tar_backup.py

The plain tar takes 10,240 bytes for 5,135 bytes of text. The POSIX standard for the format explains why: a tar is a sequence of 512-byte records, with a header record before every member, every file’s data filling whole records, two records of zeros at the end, and the last block of records always written at its full size. Compressed, the same archive took between 287 and 439 bytes. add() adds a folder and everything in it in sorted order, and its filter argument, a function that add() calls for every member it writes, gave each member the same owner, time and permissions, so the sizes are the same on every run.

A tar member can be more than a regular file: a folder, a symbolic link: A file that only points to another path. Opening it opens what it points to, so a link inside a folder can lead anywhere on the computer; Path.resolve() follows links to the real location. More in “pathlib: paths, folders and file operations”, a hard link or a device file, each with an owner and permission bits. That is what makes tar the right format to back up a Unix system, and it is also what makes an unknown tar file risky to unpack, as the section on the 'data' filter shows.

Checkpoint

Check: tar archives

  1. Question 1 of 2 Which tarfile.open() mode writes a tar archive compressed with Zstandard?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "w:zst"

    The mode is "w:" followed by the compressor: gz, bz2, xz or, from Python 3.14, zst. For reading, "r:*" detects the compression on its own.

  2. Question 2 of 2 In tar_backup.py, the three text files hold 5,135 bytes, yet notes.tar takes 10,240. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: tar writes a 512-byte header record for every member, fills whole 512-byte records with each file and writes its last block at full size

    A tar is a sequence of 512-byte records: a header record per member, the data filling whole records, two records of zeros at the end, and the last block of records written at its full size. That framing is why tar is nearly always compressed.

Text shrinks to a quarter or less, random bytes not at all

The four compression modules of this lesson share the same core: compress(data) returns smaller bytes, decompress(data) returns the exact original, and open(filename, mode) gives a file object that compresses as you write, for data too large to hold in memory, such as gzip.open("server.log.gz", "wt", encoding="utf-8"). Python 3.14 groups them in the compression package (PEP 784): compression.gzip, compression.bz2, compression.lzma and compression.zlib are the existing modules under new names, which the documentation calls the preferred ones, and compression.zstd is new. The old names keep working.

The example compresses two inputs of exactly the same size: a CSV export of 4,000 orders and the same number of random bytes, which behave like a photo or a video that is already compressed:

One CSV and one random file, compressed four ways Python · codec_sizes.py
import random
from compression import bz2, gzip, lzma, zstd

# Two inputs of the same size: a CSV export (text with repeated words) and random bytes,
# which behave like a photo or video that is already compressed.
rng = random.Random(7)
cities = ["Pune", "Kochi", "Indore", "Nagpur", "Mysuru", "Surat"]
rows = ["order_id,city,amount_inr,status"]
for n in range(1, 4001):
    rows.append(f"{100000 + n},{rng.choice(cities)},{rng.randint(99, 9999)},{rng.choice(['paid', 'refunded', 'pending'])}")
text = ("\n".join(rows) + "\n").encode("utf-8")
noise = rng.randbytes(len(text))

codecs = {
    "gzip (level 9)": (gzip.compress, gzip.decompress),
    "bz2 (level 9)": (bz2.compress, bz2.decompress),
    "lzma (preset 6)": (lzma.compress, lzma.decompress),
    "zstd (level 3)": (zstd.compress, zstd.decompress),
}

for label, data in [("CSV text", text), ("random bytes", noise)]:
    print(f"{label}: {len(data):,} bytes")
    for name, (pack, unpack) in codecs.items():
        packed = pack(data)
        assert unpack(packed) == data  # lossless: the exact bytes come back
        print(f"  {name:<16} {len(packed):>7,} bytes  {len(packed) / len(data):6.1%} of the original")

# A higher zstd level trades time for size; level 19 is near the top of the normal range.
for level in (1, 3, 19):
    print(f"zstd level {level:>2}: {len(zstd.compress(text, level=level)):,} bytes")

Output

CSV text: 102,311 bytes
  gzip (level 9)    24,472 bytes   23.9% of the original
  bz2 (level 9)     19,590 bytes   19.1% of the original
  lzma (preset 6)   20,668 bytes   20.2% of the original
  zstd (level 3)    25,534 bytes   25.0% of the original
random bytes: 102,311 bytes
  gzip (level 9)   102,364 bytes  100.1% of the original
  bz2 (level 9)    103,127 bytes  100.8% of the original
  lzma (preset 6)  102,376 bytes  100.1% of the original
  zstd (level 3)   102,323 bytes  100.0% of the original
zstd level  1: 24,533 bytes
zstd level  3: 25,534 bytes
zstd level 19: 21,612 bytes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 codec_sizes.py

The table puts each method’s compression ratio: How much compression shrinks data, given in this track as the compressed size as a share of the original: 23.9 % for a CSV export with gzip at level 9, about 100 % for random bytes. on the two inputs side by side:

Compressor CSV text Random bytes
gzip, level 9 23.9 % 100.1 %
bz2, level 9 19.1 % 100.8 %
lzma, preset 6 20.2 % 100.1 %
zstd, level 3 25.0 % 100.0 %

The CSV shrank to between 19 % and 25 % of its size with every method, because text repeats itself: the same city names, the same status words, the same digits. The random bytes did not shrink at all; each compressor added a few bytes of headers, and bz2 added 816. That is the rule to remember: compressing JPEG photos, MP4 videos or zip files again costs time and gains nothing. The last lines show the level knob: zstd at level 19 beat gzip and came within about 10 % of bz2, and level 1 was even a little smaller than the default level 3 on this input, a reminder that the effect of a level depends on the data. Measure on your own files before choosing.

Checkpoint

Check: What compression gains

  1. One question Which of these usually shrink a lot when you compress them with gzip or zstd?

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • A server's text log
    • A CSV export of orders

    Text with repeated words and patterns compresses well; in the lesson's run a CSV shrank to between 19 % and 25 % of its size. JPEG, MP4 and zip data are compressed already and look random to a compressor, so they hardly change and can even grow by a few bytes.

Python Online Compiler Change the inputs of codec_sizes.py and see which compressor wins on your own data.

Speed is the other half of the choice

Size is half of the trade. The second example times each compressor on about 0.4 MB of the same kind of text, taking the best of three runs:

How long each compressor takes on 0.4 MB of text Python · codec_speed.py
import random
import time
from compression import bz2, gzip, lzma, zstd

# About 0.4 MB of CSV-like text, compressed and decompressed with each module.
rng = random.Random(7)
cities = ["Pune", "Kochi", "Indore", "Nagpur", "Mysuru", "Surat"]
lines = [f"{100000 + n},{rng.choice(cities)},{rng.randint(99, 9999)},paid" for n in range(16000)]
data = "\n".join(lines).encode("utf-8")

codecs = [
    ("gzip level 9", lambda d: gzip.compress(d), gzip.decompress),  # gzip.compress() defaults to 9
    ("gzip level 6", lambda d: gzip.compress(d, compresslevel=6), gzip.decompress),
    ("bz2 level 9", lambda d: bz2.compress(d), bz2.decompress),
    ("lzma preset 6", lambda d: lzma.compress(d), lzma.decompress),
    ("zstd level 3", lambda d: zstd.compress(d), zstd.decompress),
]


def best_ms(func, arg, repeat=3):
    """The fastest of several runs, in milliseconds (the slower ones were disturbed by something else)."""
    times = []
    for _ in range(repeat):
        start = time.perf_counter()
        func(arg)
        times.append(time.perf_counter() - start)
    return min(times) * 1000


print(f"input: {len(data):,} bytes")
for name, pack, unpack in codecs:
    packed = pack(data)
    print(f"{name:<13} {len(packed):>7,} bytes  compress {best_ms(pack, data):6.1f} ms  decompress {best_ms(unpack, packed):5.1f} ms")

Output

input: 371,867 bytes
gzip level 9   87,961 bytes  compress   90.4 ms  decompress   0.8 ms
gzip level 6   88,585 bytes  compress   23.0 ms  decompress   0.5 ms
bz2 level 9    76,247 bytes  compress   29.4 ms  decompress  13.0 ms
lzma preset 6  66,084 bytes  compress  120.8 ms  decompress   5.2 ms
zstd level 3   96,849 bytes  compress    1.6 ms  decompress   0.5 ms

This output changes from run to run: the times depend on the computer and on what else it is doing; the sizes are the same on every run

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 codec_speed.py

The sizes are the same on every computer; the times are this run’s. In the recorded run, zstd compressed the text in a few milliseconds, many times faster than any other method, while lzma made the smallest file and was the slowest to compress. gzip.compress() defaults to level 9, its slowest, which made the file less than 1 % smaller than level 6 for a lot of extra time. Decompressing was quickest with gzip and zstd, and slowest with bz2.

Which compressor fits depends on who reads the files, how often they are read back and what matters most. Answer the three questions in the table: it rules out what does not fit your case and says why.

Interactive · answer and compare Which compressor fits your files? Answer the three questions about your files. The table keeps every option and says which ones your answers rule out, and why.
Who will read the compressed files?
How often is each file read back?
What matters most?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four compressors against size, speed and who can read the result, on the lesson's CSV and text
Compressor Size of the CSV Time to compress Time to decompress Who can read it
gzip File extension: .gz Mixed 24,472 bytes at level 9, 23.9 % of the CSV Mixed slow at its default level 9, much faster at level 6 in the recorded run Good among the fastest in the recorded run Good every tool, and browsers over HTTP
bz2 File extension: .bz2 Good 19,590 bytes, 19.1 %: the smallest on the CSV Mixed slower than gzip at level 6 in the recorded run Poor the slowest to decompress in the recorded run Mixed most Unix tools
lzma (xz) File extension: .xz Good 20,668 bytes on the CSV, and the smallest on the larger text Poor the slowest to compress in the recorded run Mixed between gzip and bz2 in the recorded run Mixed most Unix tools
zstd File extension: .zst Mixed 25,534 bytes at level 3, 21,612 at level 19 Good many times faster than any other in the recorded run Good among the fastest in the recorded run Mixed Python 3.14, the zstd tool and recent software
How the answers decide
  • “Who will read the compressed files?” Anyone, with any tool or a browser: rules out zstd and adds a caveat to bz2 and lzma (xz). Python before 3.14 and many older tools cannot read .zst without extra software, and no browser reads .bz2 or .xz, which need the tools most Unix systems have.
  • “How often is each file read back?” Often, as logs and backups are: rules out bz2 and adds a caveat to lzma (xz). bz2 was the slowest to decompress in the recorded run, and lzma slower than gzip and zstd.
  • “What matters most?” The smallest file: rules out gzip and adds a caveat to zstd. On the CSV, bz2 and lzma made the smallest files; zstd came close only at level 19, and gzip stayed above both.
  • “What matters most?” Speed: rules out lzma (xz) and bz2. lzma was the slowest to compress and bz2 the slowest to decompress, while zstd was the fastest at both.

In short: gzip for files anyone must read and for web responses, zstd for logs, backups and data you read back often on systems you control, lzma (xz) for the smallest download compressed once, and bz2 mainly to read the .bz2 files others made.

Checkpoint

Check: Choosing a compressor

  1. One question In the recorded run of codec_speed.py, what did gzip's default level 9 buy over level 6?

    Choose one answer.

    Show the answer: One question

    Answer: A file less than 1 % smaller, for far more time

    gzip.compress() defaults to level 9, its slowest. On the lesson's text it made the file less than 1 % smaller than level 6, so level 6 is often the better choice when speed matters.

Gzip & Deflate Compressor / Decompressor Compress text with gzip or zlib and compare the sizes with what Python produced.

The ‘data’ filter refuses tar members that leave the folder

An archive is a list of names chosen by whoever made it, and extracting it means writing files under those names. A member named ../outside.txt, an absolute name such as /var/data/report.txt or a link that points elsewhere can make an extractor write outside the folder you gave it, the weakness called path traversal: A weakness in which a name from outside, such as ../../etc/passwd, makes a program read or write a file outside the folder it meant to use. Resolving the joined path and checking that it is still inside the folder prevents it. More in “pathlib: paths, folders and file operations”. A tiny archive can also declare members that unpack to gigabytes, a decompression bomb: A small compressed file that unpacks into an enormous one, made to fill a disk or memory. Checking the sizes an archive declares and counting the bytes actually written stop it. that fills the disk. The documentation of zipfile, tarfile and shutil.unpack_archive() carries the same warning: never extract archives from untrusted sources without inspecting them first.

tar archives have extraction filter: In tarfile, a function that sees each member before it is extracted and may refuse or change it. The 'data' filter, the default from Python 3.14, refuses names and links that lead outside the destination., added in Python 3.12 (PEP 706). The filter argument of extract() and extractall() decides what each member may do. "fully_trusted" honours everything the archive says. "tar" blocks the worst features: it strips leading slashes, refuses members that would land outside the destination and clears the setuid, setgid and sticky bits. "data", meant for archives that only carry data, also refuses links to absolute paths or outside the destination and device files, ignores owners and keeps only safe permission bits. Since Python 3.14, "data" is the default when you pass no filter. The example builds a test archive in memory and extracts it member by member:

Members the 'data' filter refuses Python · tar_filter.py
import io
import tarfile
from pathlib import Path


def add_file(tar, name, text):
    data = text.encode("utf-8")
    info = tarfile.TarInfo(name)
    info.size = len(data)
    tar.addfile(info, io.BytesIO(data))


def add_symlink(tar, name, target):
    info = tarfile.TarInfo(name)
    info.type = tarfile.SYMTYPE
    info.linkname = target
    tar.addfile(info)


# A test archive: one ordinary file and four members whose names or links point outside the
# folder it is unpacked into. It is built in memory, so the example needs no files of its own.
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
    add_file(tar, "report.txt", "quarterly numbers\n")
    add_file(tar, "../outside.txt", "written next to the destination\n")
    add_file(tar, "/abs/path.txt", "an absolute name\n")
    add_symlink(tar, "shared", "/var/data/shared.txt")
    add_symlink(tar, "escape", "../../..")

dest = Path("unpacked")
buffer.seek(0)
with tarfile.open(fileobj=buffer, mode="r:gz") as tar:
    for member in tar.getmembers():
        try:
            tar.extract(member, dest, filter="data")
            print(f"{member.name:<15} extracted")
        except tarfile.FilterError as err:
            print(f"{member.name:<15} refused: {type(err).__name__}")

print("files in unpacked:", sorted(p.relative_to(dest).as_posix() for p in dest.rglob("*")))
print("outside.txt was written outside:", Path("outside.txt").exists())

# Without a filter argument, Python 3.14 uses "data" too: extractall() stops at the first refusal.
buffer.seek(0)
with tarfile.open(fileobj=buffer, mode="r:gz") as tar:
    try:
        tar.extractall("default")
    except tarfile.FilterError as err:
        print(f"extractall() stopped at {err.tarinfo.name!r} with {type(err).__name__}")

Output

report.txt      extracted
../outside.txt  refused: OutsideDestinationError
/abs/path.txt   extracted
shared          refused: AbsoluteLinkError
escape          refused: LinkOutsideDestinationError
files in unpacked: ['abs', 'abs/path.txt', 'report.txt']
outside.txt was written outside: False
extractall() stopped at '../outside.txt' with OutsideDestinationError

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tar_filter.py

Step through what the filter did with each member:

Interactive · stage by stage What the 'data' filter does with five members Step through the members of tar_filter.py. Each one goes down the filter's checks until it is refused or extracted into unpacked.
What the 'data' filter does with five members, step 6 of 6. Showing A member of the archive (extractall() on 3.14), Leading slashes stripped (now abs/path.txt), Lands inside the folder?, Links stay inside?, A device file or a pipe?, Extracted, owner ignored. In focus: Lands inside the folder?.A member of the archiveextractall() on 3.14Leading slashes strippednow abs/path.txtLands inside the folder?else OutsideDestinationErrorLinks stay inside?else AbsoluteLinkError or …A device file or a pipe?refused with SpecialFileErrorExtracted, owner ignoredsafe permission bits

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. report.txt. An ordinary file with an ordinary name passes every check and is written to unpacked/report.txt. The filter drops the owner and group the archive records and keeps only safe permission bits.
  2. ../outside.txt. The name climbs out of unpacked, so the filter refuses the member with OutsideDestinationError and nothing is written; outside.txt never appears next to the folder.
  3. /abs/path.txt. The leading slash is stripped, so the name becomes abs/path.txt, which lands inside the folder. The member is extracted to unpacked/abs/path.txt.
  4. shared. A symbolic link to /var/data/shared.txt. A link to an absolute path could lead anywhere, so the filter refuses it with AbsoluteLinkError.
  5. escape. A link to ../../.. points outside the destination, so the filter refuses it with LinkOutsideDestinationError, before anything could be written through it.
  6. The default in Python 3.14. extractall() without a filter uses 'data' too and stops at the first refusal, ../outside.txt. Members extracted before it stay on disk, so extract into a fresh folder and delete it when extraction fails.

report.txt came out normally, and the absolute name was made relative, so /abs/path.txt landed at unpacked/abs/path.txt. The other three were refused with exceptions that all derive from tarfile.FilterError, and outside.txt was never written. The last line shows the default at work: extractall() without a filter raised at the first refused member. Members extracted before the error stay on disk, so extract into a fresh folder and delete it if extraction fails.

Security

On Python 3.12 and 3.13 the default still trusts the archive fully, so pass filter="data" explicitly in code that runs on those versions. Security releases of older versions received the filters too: test for them with hasattr(tarfile, "data_filter") rather than by version number. The tarfile documentation adds that even "data" does not block every danger, in particular not denial of service by huge or numerous members: check sizes and counts yourself as well.

Checkpoint

Check: Extracting tar archives safely

  1. Question 1 of 2 On Python 3.14, tarfile.open("upload.tar").extractall("dest") meets a member named ../outside.txt. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Extraction stops with OutsideDestinationError, because the default filter is now 'data'

    Since Python 3.14 the extraction methods use the 'data' filter when no filter is passed, and that filter refuses a member that would land outside the destination. With the default errorlevel of 1 the refusal is raised, so extractall() stops there; files extracted before it stay on disk.

  2. Question 2 of 2 An archive holds a symbolic link named shared that points to /var/data/shared.txt. What does the 'data' filter do with it?
    Show the answer: Question 2 of 2

    Answer: It refuses it with AbsoluteLinkError

    The 'data' filter refuses links that point to absolute paths or outside the destination, with AbsoluteLinkError or LinkOutsideDestinationError, so nothing can be written through them later.

zip rewrites dangerous names, so check names and sizes yourself

zip archives have no filter argument. Instead, ZipFile.extract() and extractall() rewrite dangerous names: they strip leading slashes and drive letters and remove .. parts. The example shows what that means for an upload:

What extractall() does with suspicious names Python · zip_names.py
import zipfile
from pathlib import Path, PurePosixPath

# A zip with suspicious member names, built in memory and saved like an upload.
with zipfile.ZipFile("upload.zip", "w") as zf:
    zf.writestr("photos/beach.jpg", b"not really a photo")
    zf.writestr("../../settings.ini", b"[app]\ntheme = dark\n")
    zf.writestr("/var/data/report.txt", b"an absolute name\n")


def suspicious(name):
    """True for names that are absolute or climb out of the destination with '..'."""
    path = PurePosixPath(name.replace("\\", "/"))
    return path.is_absolute() or ".." in path.parts


with zipfile.ZipFile("upload.zip") as zf:
    for name in zf.namelist():
        print(f"{name:<22} suspicious: {suspicious(name)}")
    zf.extractall("out")

# extractall() rewrites dangerous names instead of refusing them, so nothing escapes "out",
# but files still land under names the archive's author chose.
print(sorted(p.relative_to("out").as_posix() for p in Path("out").rglob("*") if p.is_file()))

Output

photos/beach.jpg       suspicious: False
../../settings.ini     suspicious: True
/var/data/report.txt   suspicious: True
['photos/beach.jpg', 'settings.ini', 'var/data/report.txt']

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 zip_names.py

The table follows each member from its name in the archive to the file that extractall() wrote:

Member name suspicious() Written by extractall("out") to
photos/beach.jpg False out/photos/beach.jpg
../../settings.ini True out/settings.ini
/var/data/report.txt True out/var/data/report.txt

Nothing left the out folder, but nothing was refused either: ../../settings.ini arrived as settings.ini, a name the archive’s author picked, which may overwrite a file your program expects there. For uploads, check every name before you extract anything and refuse the whole archive when one looks wrong, as suspicious() does: a name is dangerous when it is absolute or when its parts include .., with forward slashes and backslashes both counted as separators. zipfile.Path, the pathlib-style interface to a zip, rewrites nothing at all, and its documentation leaves validating names to the caller.

Sizes need a check too, and the archive’s own claim is only the first of two. The next example makes a test upload for your own extractor, a megabyte of zeros, and runs both checks on it:

A size limit checked twice: declared and written Python · zip_limits.py
import zipfile
from pathlib import Path

# A test upload for your own extractor: 1 MB of zeros, which DEFLATE stores in very little space.
with zipfile.ZipFile("upload.zip", "w", compression=zipfile.ZIP_DEFLATED) as zf:
    zf.writestr("zeros.bin", bytes(1_000_000))
    zf.writestr("note.txt", b"a short note\n")
with zipfile.ZipFile("normal.zip", "w", compression=zipfile.ZIP_DEFLATED) as zf:
    zf.writestr("note.txt", b"a short note\n")

LIMIT = 100_000  # the most this service unpacks from one upload


def declared_total(zf):
    """The bytes the members say they unpack to: the archive's own claim, read before anything is written."""
    return sum(info.file_size for info in zf.infolist())


def copy_capped(zf, info, path, budget):
    """Write one member to path in chunks, and stop once it has produced more than budget bytes."""
    written = 0
    with zf.open(info) as src, open(path, "wb") as out:
        while chunk := src.read(65536):
            written += len(chunk)
            if written > budget:
                raise ValueError(f"{info.filename} produced more than {budget:,} bytes")
            out.write(chunk)
    return written


size = Path("upload.zip").stat().st_size
with zipfile.ZipFile("upload.zip") as zf:
    total = declared_total(zf)
    print(f"upload.zip: {size:,} bytes, members declare {total:,} bytes ({total // size:,} times as many)")
    print(f"declared-size check: {'refused' if total > LIMIT else 'accepted'} (limit {LIMIT:,} bytes)")
    try:
        copy_capped(zf, zf.getinfo("zeros.bin"), "zeros.bin", LIMIT)
    except ValueError as err:
        print(f"capped copy: stopped, {err}; {Path('zeros.bin').stat().st_size:,} bytes on disk")

with zipfile.ZipFile("normal.zip") as zf:
    total = declared_total(zf)
    print(f"normal.zip: members declare {total:,} bytes: {'refused' if total > LIMIT else 'accepted'}")

Output

upload.zip: 1,208 bytes, members declare 1,000,013 bytes (827 times as many)
declared-size check: refused (limit 100,000 bytes)
capped copy: stopped, zeros.bin produced more than 100,000 bytes; 65,536 bytes on disk
normal.zip: members declare 13 bytes: accepted

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 zip_limits.py

The 1,208-byte upload declared 1,000,013 bytes, 827 times as many, so adding up the members’ file_size refused it before anything was written. The declared sizes come from the archive, though, so a careful extractor also counts the bytes it actually writes: copy_capped() reads the member with zf.open(info) in chunks and stopped once the member had produced more than the limit, with 65,536 bytes on disk. The normal upload passed both checks. The exercise below builds the name and size checks into one function.

Key idea

An archive chooses its own names and sizes: check every name and the declared total before you extract, and count the bytes you write while you do.

Checkpoint

Check: Extracting zips safely

  1. Question 1 of 2 ZipFile.extractall("out") meets a member named ../../settings.ini. Where does it write it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: To out/settings.ini, because extractall() removes the .. parts

    extract() and extractall() strip leading slashes and drive letters and remove .. parts, so the member is written to out/settings.ini. Nothing escapes, but an upload may still overwrite a file your program expects.

  2. Question 2 of 2 A careful extractor checks the sizes the members declare. Why does it also count the bytes it actually writes?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The declared sizes are part of the archive, so whoever made it can make them say anything

    The sum of file_size refuses an obvious bomb before anything is written, and copy_capped() stops any member that produces more than the limit, whatever its header claimed.

ZIP & RAR Extractor Look inside a zip, tar or gz file in your browser and read every member's name before you unpack it.

Practise: back up a folder and unzip uploads safely

The exercise asks for backup(), which zips a folder under relative names with DEFLATE, and safe_unzip(), which refuses an archive with an absolute name, a .. part or a declared total above a limit before it writes anything, and otherwise extracts it. Its sample tests run in your browser:

Exercise · Medium · Python

Back up a folder and unzip uploads safely

Write two functions in archive_tools.py. Keep the names and parameters: the tests import them.

backup(folder, dest) puts every file under folder, in subfolders too, into one zip archive and returns the archive's path.

  • The archive is dest/<name of the folder>.zip, so backing up photos into backups writes backups/photos.zip. Create dest when it does not exist.
  • Names inside the archive are relative to folder and use forward slashes, such as a.txt and trips/goa.txt. Store the files compressed (ZIP_DEFLATED).

safe_unzip(zip_path, dest, max_bytes=50_000_000) extracts an archive that came from someone else, and refuses a dangerous one before it writes anything.

  • Raise ValueError if any member's name is absolute (it starts with / or \, or with a drive such as C:) or contains a .. part, with either kind of slash.
  • Raise ValueError if the members would unpack to more than max_bytes bytes in total, according to the sizes the archive declares.
  • Otherwise extract every member into dest and return the names of the files it holds, sorted, without directory entries.

Starter code · archive_tools.py

import zipfile
from pathlib import Path, PurePosixPath


def backup(folder, dest):
    """Zip every file under folder into dest/<folder name>.zip and return the archive's path."""
    raise NotImplementedError("write backup()")


def safe_unzip(zip_path, dest, max_bytes=50_000_000):
    """Extract zip_path into dest after checking every member; raise ValueError for a dangerous archive."""
    raise NotImplementedError("write safe_unzip()")
The sample tests · test_archive_tools.py
import tempfile
import zipfile
from pathlib import Path

from archive_tools import backup, safe_unzip


def make_folder():
    root = Path(tempfile.mkdtemp())
    folder = root / "photos"
    (folder / "trips").mkdir(parents=True)
    (folder / "a.txt").write_text("first file\n")
    (folder / "trips" / "goa.txt").write_text("beach day\n" * 50)
    return root, folder


def make_zip(names, size=10):
    path = Path(tempfile.mkdtemp()) / "upload.zip"
    with zipfile.ZipFile(path, "w") as zf:
        for name in names:
            zf.writestr(name, b"x" * size)
    return path


def test_backup_path():
    """backup() writes dest/<folder name>.zip and returns that path"""
    root, folder = make_folder()
    archive = backup(folder, root / "backups")
    assert Path(archive) == root / "backups" / "photos.zip"
    assert Path(archive).is_file()


def test_backup_creates_nested_dest_and_runs_twice():
    """dest may be several folders deep, and a second backup into it works too"""
    root, folder = make_folder()
    dest = root / "backups" / "daily"
    backup(folder, dest)
    archive = backup(folder, dest)
    assert Path(archive) == dest / "photos.zip"


def test_backup_names_and_contents():
    """the archive holds every file under relative names, compressed"""
    root, folder = make_folder()
    with zipfile.ZipFile(backup(folder, root / "out")) as zf:
        assert sorted(zf.namelist()) == ["a.txt", "trips/goa.txt"]
        assert zf.read("trips/goa.txt") == b"beach day\n" * 50
        assert zf.getinfo("trips/goa.txt").compress_type == zipfile.ZIP_DEFLATED


def test_safe_unzip_extracts_normal_archive():
    """a normal archive is extracted and its file names come back sorted"""
    archive = make_zip(["b.txt", "docs/", "docs/a.txt"])
    dest = Path(tempfile.mkdtemp()) / "dest"
    assert safe_unzip(archive, dest) == ["b.txt", "docs/a.txt"]
    assert (dest / "docs" / "a.txt").read_bytes() == b"x" * 10


def test_rejects_parent_parts():
    """a name with .. is refused before anything is written"""
    archive = make_zip(["ok.txt", "../evil.txt"])
    dest = Path(tempfile.mkdtemp()) / "dest"
    try:
        safe_unzip(archive, dest)
    except ValueError:
        assert not (dest / "ok.txt").exists()
        return
    assert False, "safe_unzip accepted ../evil.txt"


def test_rejects_absolute_and_backslash_names():
    """absolute names, drive letters and backslash tricks are refused"""
    for bad in ["/etc/thing.txt", "C:/thing.txt", "..\\up.txt", "a/b/../../../c.txt"]:
        try:
            safe_unzip(make_zip([bad]), Path(tempfile.mkdtemp()))
        except ValueError:
            continue
        assert False, f"safe_unzip accepted {bad!r}"


def test_rejects_too_large():
    """an archive that would unpack to more than max_bytes is refused"""
    archive = make_zip(["big.bin"], size=5000)
    try:
        safe_unzip(archive, Path(tempfile.mkdtemp()), max_bytes=4999)
    except ValueError:
        return
    assert False, "safe_unzip ignored max_bytes"
A hint

For backup(), Path(folder).rglob("*") walks every file and folder below folder; skip folders with is_file(), and give ZipFile.write() an arcname from path.relative_to(folder).as_posix(). For safe_unzip(), check every name in infolist() before calling extractall(): replace backslashes with slashes, turn the name into a PurePosixPath, and look at is_absolute() and parts. Each ZipInfo has a file_size, the size it declares once unpacked.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Formats, zip, tar, both halves of compression and both kinds of safe extraction are in the practice below. Your weakest idea comes up first, a right answer leads to a harder question, and every choice explains itself:

Practice that adapts to you

Practise this lesson

14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 Why do tar files often have two extensions, such as .tar.gz?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: tar collects the files into one stream, and gzip then compresses that whole stream

    A .tar.gz is a tar archive whose whole stream is compressed with gzip; .tar.bz2, .tar.xz and .tar.zst use other compressors around the same tar format.

  2. Question 2 of 14 The lesson's three notes took 568 bytes as a zip and 335 as a .tar.gz. Why was the .tar.gz smaller?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: gzip compressed the whole tar stream at once, so it could reuse words repeated across the three files

    Compressing the whole stream lets the compressor see repetition across files, at the cost of reading every member before the one you want. A zip trades a little size for reading any member directly.

  3. Question 3 of 14 What does ZipFile.testzip() return for an archive whose members are all intact?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: None

    testzip() reads all the members and checks their CRCs and headers. zip_backup.py printed damaged members: None for its backup.

  4. Question 4 of 14 zip_backup.py stored notes/trips/goa.txt, 1,960 bytes, as 69 bytes. Which argument made that possible?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: compression=zipfile.ZIP_DEFLATED

    The default is ZIP_STORED. DEFLATE, the method every zip tool understands, turned the 1,960 bytes of repetitive text into 69.

  5. Question 5 of 14 Which kinds of member can a tar archive hold?

    Choose every answer that is right.

    Show the answer: Question 5 of 14

    Answer:

    • Symbolic links and hard links
    • Folders
    • Regular files

    tar members can be files, folders, symbolic and hard links and device files, each with an owner and permission bits. That is what makes tar right for Unix backups and risky to unpack from strangers.

  6. Question 6 of 14 In the run of tar_backup.py, which archive of the notes folder was the smallest?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: notes.tar.zst, 287 bytes

    On three small text files zstd came first, then gzip, xz and bz2. On the larger CSV the order was different, which is why the lesson measures on the data before choosing.

  7. Question 7 of 14 What does zstd.decompress(zstd.compress(data)) return?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: Exactly data, byte for byte

    All four modules compress losslessly: decompress() returns exactly the original bytes, which the example checks with an assert for every input.

  8. Question 8 of 14 Compressing the 102,311 random bytes of codec_sizes.py with bz2 gave 103,127 bytes. Why did they grow?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: Random bytes have no repetition to remove, and the format still adds its own headers and blocks

    Random data, like photos and videos that are already compressed, cannot shrink. Every compressor added a few bytes, and bz2 added 816.

  9. Question 9 of 14 Which level does gzip.compress() use when you give none?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: 9, its slowest and strongest

    gzip.compress() defaults to level 9. Pass compresslevel=6 when speed matters more than the last few bytes.

  10. Question 10 of 14 A service writes large logs that only its own tools read, and they are read back often. Which compressor fits best?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: zstd

    For data you write and read back often, on systems you control, speed matters most, and zstd was the fastest in the recorded run. Choose gzip when anyone must be able to read the files, and lzma for a download compressed once and read by many.

  11. Question 11 of 14 The same extraction code must run on Python 3.12 in production and 3.14 in development. What do you change so that both extract safely?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: Pass filter="data" to extractall() explicitly, and check hasattr(tarfile, "data_filter") on older interpreters

    Pass filter="data" wherever you extract, and test for the feature with hasattr(tarfile, "data_filter") rather than by version. Then add your own limits: the filters do not stop huge or numerous members.

  12. Question 12 of 14 extractall() refuses the third member of an archive. What is on disk?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: The members extracted before the refusal, so extract into a fresh folder and delete it on failure

    Members written before the refusal stay. Extracting into a new, empty folder makes the cleanup a single delete, and avoids links that already exist in an old folder.

  13. Question 13 of 14 What does zip_names.py print?

    What does this program print? Choose one answer.

    import zipfile
    from pathlib import Path, PurePosixPath
    
    # A zip with suspicious member names, built in memory and saved like an upload.
    with zipfile.ZipFile("upload.zip", "w") as zf:
        zf.writestr("photos/beach.jpg", b"not really a photo")
        zf.writestr("../../settings.ini", b"[app]\ntheme = dark\n")
        zf.writestr("/var/data/report.txt", b"an absolute name\n")
    
    
    def suspicious(name):
        """True for names that are absolute or climb out of the destination with '..'."""
        path = PurePosixPath(name.replace("\\", "/"))
        return path.is_absolute() or ".." in path.parts
    
    
    with zipfile.ZipFile("upload.zip") as zf:
        for name in zf.namelist():
            print(f"{name:<22} suspicious: {suspicious(name)}")
        zf.extractall("out")
    
    # extractall() rewrites dangerous names instead of refusing them, so nothing escapes "out",
    # but files still land under names the archive's author chose.
    print(sorted(p.relative_to("out").as_posix() for p in Path("out").rglob("*") if p.is_file()))
    Show the answer: Question 13 of 14

    Answer: it prints

    photos/beach.jpg       suspicious: False
    ../../settings.ini     suspicious: True
    /var/data/report.txt   suspicious: True
    ['photos/beach.jpg', 'settings.ini', 'var/data/report.txt']

    suspicious() flags the name with .. parts and the absolute name. extractall() does not refuse either member: it drops the .. parts and the leading slash, so all three files land inside out, under settings.ini and var/data/report.txt. Nothing escapes the folder, but the archive still chose those names.

  14. Question 14 of 14 A 1,208-byte upload declares 1,000,013 bytes, as in zip_limits.py, and your service accepts at most 100,000. What should the extractor do?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Refuse it before writing anything, because the declared total is over the limit

    Check the declared total first, then cap the bytes actually written while extracting, as copy_capped() does, so neither an honest nor a lying archive can exceed the limit.

Interview questions

What is the difference between an archive format and a compression format?

An archive format collects many files into one and records their names, sizes and other metadata, such as tar, which also keeps Unix permissions and links. A compression format encodes a stream of bytes in fewer bytes and decodes it back exactly, such as gzip, bz2, xz or Zstandard; it knows nothing about files or names. Formats combine: a .tar.gz is a tar archive whose whole stream is compressed with gzip, while a zip is both an archive and a compressor at once, because it compresses each member separately and lists them in a central directory at the end. The difference shows up in use: a zip lets a program read one member directly, while a .tar.gz must be decompressed from the start, but often comes out smaller because the compressor sees repetition across all the files.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A service accepts .tar.gz uploads and unpacks them with tarfile.open(path).extractall(dest). Production runs Python 3.11 and development 3.14. What can go wrong with archives from strangers, and what do you change so that both versions extract them safely?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Users upload zip files of documents, and a worker extracts them with ZipFile.extractall(). One night a 40 MB upload filled the server's 40 GB disk. How is that possible, and how do you design the extraction so that it cannot happen again?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • An archive bundles files under names and a compressor shrinks bytes: zip does both per member and can read one member directly, while tar only archives and is wrapped by gzip, bz2, xz or zstd as one stream.
  • zipfile.ZipFile stores members uncompressed unless you pass compression=zipfile.ZIP_DEFLATED; give write() a relative arcname, and let shutil.make_archive() handle the common formats in one call.
  • A plain tar is a sequence of 512-byte records, 10,240 bytes for the lesson’s notes, so it is nearly always compressed; tarfile.open() modes such as "w:gz" and, from Python 3.14, "w:zst" choose the compressor.
  • Text shrinks to a quarter of its size or less while compressed media does not shrink at all; choose a compressor by who reads the files and how often: in the lesson’s runs zstd was the fastest, and bz2 and lzma made the smallest files.
  • For tar, rely on the "data" filter, the default from Python 3.14, and pass it explicitly on 3.12 and 3.13.
  • For zip, check every name and the declared total before extracting, and cap the bytes you actually write.

Terms in this lesson

central directory
The index at the end of a zip file that lists every member's name, its sizes and where its data starts, so that a program can read one member without decompressing the others.
compression ratio
How much compression shrinks data, given in this track as the compressed size as a share of the original: 23.9 % for a CSV export with gzip at level 9, about 100 % for random bytes.
decompression bomb
A small compressed file that unpacks into an enormous one, made to fill a disk or memory. Checking the sizes an archive declares and counting the bytes actually written stop it.
extraction filter
In tarfile, a function that sees each member before it is extracted and may refuse or change it. The 'data' filter, the default from Python 3.14, refuses names and links that lead outside the destination.
lossless compression
Compression whose decompress step gives back exactly the original bytes, as gzip, bz2, lzma and zstd do. Text with repeated patterns shrinks a lot; data that is already compressed hardly shrinks at all.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.