LESSON 80 · DEEPER PYTHON30 min

Create ZIP Archives in Memory

Create a ZIP archive from simple named text entries and inspect its contents without extraction.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Create ZIP Archives in Memory

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    A ZIP file seems simple from the outside: several named files become one package. In Python, zipfile lets us create and inspect that package. For a browser lesson, we can do it entirely in memory with io.BytesIO, so the examples do not write files and do not need network access. Today we will first reproduce a real type error after reading a ZIP entry. The entry content comes back as bytes, even when we originally wrote human- readable text. Then we will make the text-to-bytes conversion explicit, validate the entry names we accept, and create a predictable archive from a dictionary. The finished task sorts simple basenames, encodes their text as UTF-8, and returns

  2. 00:43

    the completed ZIP bytes. Finally we will read those bytes back, decode one entry, and reject a path-like name. That last test is important because an archive can contain names that should not be used as filesystem paths without a separate policy. This tutorial never extracts an archive. It only creates and inspects one in memory. Predict whether calling split with a Python string separator on ZIP entry bytes will work. The type probe gives us the diagnosis before the exception: data is bytes. The split call uses a string containing a space, so bytes.split rejects it.

  3. 01:24

    We could use a bytes separator, but if the application needs words as Python text, decoding is the clearer boundary. The archive does not remember that the bytes originally represented English words. The calling code must know the encoding convention used when writing them. We will use UTF-8 for both directions. Also notice when we read the archive bytes: only after the writing with block closes. A ZIP writer places directory metadata at the end of the archive, so closing it finalizes the package. Reading the buffer too early would give us an incomplete result. In this demonstration, the error does not involve compression or an invalid

  4. 02:04

    filename. It is a type mismatch between binary archive content and a text operation. Rather than catching TypeError in production and pretending a split succeeded, fix the conversion at the read boundary. We will inspect a simple round trip, then build a function that always makes the text encoding decision in one place. The name list contains the entry we wrote. read gives us the raw bytes, and decoding them with UTF-8 restores the text. The accented character occupies two bytes in that encoding, visible as escaped values in the middle line. ZIP_DEFLATED changes how the entry is stored inside the archive, but read

  5. 02:46

    returns the original uncompressed bytes. Compression does not convert bytes to strings. If we later have an empty text entry, encoding it gives empty bytes and decoding them gives an empty string, which is a valid case. We will now make a reusable function for several entries. Its interface accepts a dictionary of names to text and returns one bytes object. To keep the task well-defined, each name must be a simple basename. We will reject empty names, dot and dot- dot, slashes, backslashes, colons, and NUL characters. This policy is intentionally narrow: we are not trying to normalize arbitrary

  6. 03:26

    paths or build a general archive extractor. Sorting the names before writing makes the archive's entry order predictable for tests. Let us implement it and read the result back. The input dictionary lists notes first, but the archive contains hello first because we sort the names. That provides a simple, deterministic order for inspection. The first loop checks each name and text value before calling sorted or writestr. A mixed-type key set therefore raises our specified ValueError rather than an incidental sorting TypeError. The function encodes each text value as UTF-8 bytes, and the context manager

  7. 04:07

    closes ZipFile before buffer.getvalue returns the completed archive. The result is bytes that a caller could save or transmit if the surrounding application needs to. We do neither here. The final with block opens the archive in memory and demonstrates that namelist and read work without extraction. The name rule is a teaching contract, not a blanket guarantee for every ZIP you might receive. It prevents this function from writing parent paths or directory separators, but external archives can have different names and may expand far beyond their compressed size. The central technique is to keep archive bytes and text conversion visible rather

  8. 04:48

    than implicit. Validate early, then use the standard library's archive writer for the actual packaging. The Unicode text survives the round trip. The empty entry returns empty bytes, which is expected. The parent-path name is rejected before it can be written. We have not extracted anything, and we should keep that distinction explicit. Reading a named entry in memory is different from placing archive paths onto a filesystem. If a future project needs to extract downloaded archives, it must define allowed destinations, reject unwanted paths, and bound the decompressed size.

  9. 05:29

    A small compressed archive can expand to a much larger amount of data. Those checks are a separate, security- sensitive feature and are outside this lesson. For the checked task, focus on the precise contract we can verify here: accept simple filenames mapped to strings, sort the names, encode each value with UTF-8, write with ZipFile, close the archive, and return the finished bytes. Then reopen the bytes and inspect them. If the console shows a bytes/string type error, ask where decoding belongs rather than adding broad exception handling around text operations. On the lesson page, implement the function and add a third text entry of your own.

  10. 06:11

    Predict its position in namelist and its decoded content before running the task checker.

Understand the concept

A ZIP archive packages several named byte sequences in one file. Python's zipfile module can create and inspect one, while io.BytesIO acts like a binary file in memory. That combination keeps the exercise browser-safe: it does not create local files and it makes no network request. Open a ZipFile on a BytesIO buffer for writing, add entries with writestr, close the ZipFile, then obtain the resulting bytes with buffer.getvalue().

The task accepts a dictionary mapping simple filenames to text. It validates every name before writing: a name must be nonempty, cannot be . or .., and cannot include a slash, backslash, colon, or NUL. The lesson deliberately supports simple basenames only; it does not try to normalize arbitrary paths. It also requires each value to be a string. Sort the names to make the entry order predictable, encode each text value as UTF-8 bytes, and use ZIP_DEFLATED compression.

To inspect the result, open a second ZipFile on BytesIO(archive_bytes). namelist gives entry names, and read(name) returns bytes. Decode those bytes using the same UTF-8 encoding before treating them as text. The prepared call prints the two names and the content of hello.txt. A ZIP is a container; the compression option changes storage size, not the meaning of the text or the need to decode it.

Do not assume untrusted archive entries are safe filesystem paths. This tutorial never calls extractall and never extracts to a directory. If you later build an extractor, enforce a destination policy and resource limits, including decompressed size. Compressed files can be much larger when expanded, and archives may contain paths your application should reject. Our simple-name policy controls entries we create here, while extraction of external archives remains a separate, security-sensitive operation.

  • BytesIO supplies an in-memory binary buffer
  • ZipFile.writestr adds named byte content
  • ZipFile.read returns bytes to decode
  • Archive creation and untrusted extraction have different safety requirements

See it step by step

Read the code, predict the output, then compare it with the result.

01. Write two entries in memory

PYTHON
from io import BytesIO
from zipfile import ZipFile, ZIP_DEFLATED
buffer = BytesIO()
with ZipFile(buffer, "w", compression=ZIP_DEFLATED) as archive:
    archive.writestr("a.txt", b"Alpha")
    archive.writestr("b.txt", b"Beta")
with ZipFile(BytesIO(buffer.getvalue())) as archive:
    print(archive.namelist())
EXPECTED OUTPUT
['a.txt', 'b.txt']

Closing the writer finalizes the ZIP directory before the bytes are read back.

02. Decode the stored bytes

PYTHON
from io import BytesIO
from zipfile import ZipFile
buffer = BytesIO()
with ZipFile(buffer, "w") as archive:
    archive.writestr("hello.txt", "café".encode("utf-8"))
with ZipFile(BytesIO(buffer.getvalue())) as archive:
    print(archive.read("hello.txt").decode("utf-8"))
EXPECTED OUTPUT
café

ZipFile.read returns bytes; decoding restores the original text encoding.

03. Reject a path before writing

PYTHON
def valid_name(name):
    return bool(name) and name not in (".", "..") and not any(c in name for c in "/\\:\x00")
print(valid_name("notes.txt"))
print(valid_name("../notes.txt"))
EXPECTED OUTPUT
True
False

A simple basename policy prevents accidental path-like entry names in this example.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Write and inspect a ZIP without touching the filesystem

BytesIO provides a binary buffer in memory. ZipFile can write named entries to that buffer just as it writes to a binary file, so our example runs in the browser console. Use a with block so ZipFile closes and writes its final directory metadata before reading the resulting bytes. The entry content is also bytes; encode text to UTF-8 at the point where it enters the archive.

Open a second ZipFile on a new BytesIO containing the completed archive bytes and inspect namelist. read returns bytes, not str. The distinction matters whenever code applies text operations such as splitting on a string separator. A ZIP container does not store Python's text type; the application must remember its chosen character encoding. We use UTF-8 consistently for writing and decoding.

PYTHON
from io import BytesIO
from zipfile import ZipFile, ZIP_DEFLATED

buffer = BytesIO()
with ZipFile(buffer, "w", compression=ZIP_DEFLATED) as archive:
    archive.writestr("hello.txt", "Hi!".encode("utf-8"))
with ZipFile(BytesIO(buffer.getvalue())) as archive:
    print(archive.namelist())
    print(type(archive.read("hello.txt")).__name__)
EXPECTED OUTPUT
['hello.txt']
bytes
Follow the reasoning
  1. Close the writer before reading buffer.getvalue().
  2. Observe that a ZIP entry read is bytes even when it originated as text.
02 / 03

Validate entry names before writing them

Our function accepts only simple basenames. Reject empty names, dot and dot-dot, separators, colon, and NUL. This narrow policy is easier to verify than attempting to repair arbitrary paths. It also makes the examples independent of operating-system path rules. Reject non-string values rather than implicitly converting them with str(), because doing so could silently archive a representation the caller did not intend. Sort keys before writing to make namelist predictable.

ZIP_DEFLATED compresses the entries, but the compression choice does not remove the need for validation. ZipFile.writestr accepts the chosen entry name and encoded bytes. The returned archive is an opaque bytes object; callers can store or transmit it according to their own application requirements. The checked task does not write it to disk and does not extract its contents. Validation here applies to the archives this function creates.

PYTHON
from io import BytesIO
from zipfile import ZipFile, ZIP_DEFLATED

def make_text_archive(files):
    for name, text in files.items():
        if (not isinstance(name, str) or not name or name in (".", "..")
                or any(c in name for c in "/\\:\x00") or not isinstance(text, str)):
            raise ValueError("invalid archive entry")
    buffer = BytesIO()
    with ZipFile(buffer, "w", compression=ZIP_DEFLATED) as archive:
        for name in sorted(files):
            archive.writestr(name, files[name].encode("utf-8"))
    return buffer.getvalue()

with ZipFile(BytesIO(make_text_archive({"notes.txt": "One", "hello.txt": "Hi!"}))) as saved:
    print(saved.namelist())
    print(saved.read("hello.txt").decode("utf-8"))
EXPECTED OUTPUT
['hello.txt', 'notes.txt']
Hi!
Follow the reasoning
  1. Trace sorted names into writestr and then namelist.
  2. Decode the hello.txt bytes using the original UTF-8 choice.
03 / 03

Verify Unicode and reject path-like entries

The Unicode check confirms that a text entry survives encode, archive storage, read, and decode. An empty text value is also legitimate; it becomes an empty bytes entry. The path-like name ../outside.txt is rejected before it enters the archive. The function's rule is intentionally stricter than ZIP itself. It means simple sample archives are predictable, but it does not claim that every ZIP found elsewhere obeys the same rule.

Never use extractall on an untrusted archive without a clear destination and limits. An archive may contain surprising names or may expand to a much larger size than its compressed bytes suggest. This lesson avoids extraction entirely. If you later build an archive reader for downloads, design path checks and decompressed-size limits for that separate feature. For now, inspect entry names and bytes in memory, and treat the archive as data whose contents you explicitly choose to read.

PYTHON
from io import BytesIO
from zipfile import ZipFile

buffer = BytesIO()
with ZipFile(buffer, "w") as archive:
    archive.writestr("café.txt", "é".encode("utf-8"))
with ZipFile(BytesIO(buffer.getvalue())) as archive:
    print(archive.read("café.txt").decode("utf-8"))

def valid_name(name):
    return bool(name) and name not in (".", "..") and not any(c in name for c in "/\\:\x00")

print(valid_name("../outside.txt"))
print(valid_name("notes.txt"))
EXPECTED OUTPUT
é
False
True
Follow the reasoning
  1. Round-trip a non-ASCII filename and text value in memory.
  2. Compare a parent-path name with a valid simple basename before any extraction.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnComplete make_text_archive(files). Reject a non-string filename, a name that is empty, . or .., or contains /, \, :, or NUL; reject non-string contents with ValueError('invalid archive entry'). Validate all entries before sorting, encode text as UTF-8, and add each entry with ZipFile.writestr using ZIP_DEFLATED on an io.BytesIO buffer. Return the ZIP bytes after the ZipFile closes. The prepared call should print ['hello.txt', 'notes.txt'] and Hi!. Do not extract the archive.
Need a hint?

Validate every (name, text) pair with files.items() before sorting names, so mixed-type keys also produce the required ValueError. Then create buffer = BytesIO(); with ZipFile(buffer, 'w', compression=ZIP_DEFLATED) as archive: loop over sorted(files) and call archive.writestr(name, files[name].encode('utf-8')). Return buffer.getvalue() after leaving the with block.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.What does ZipFile.read('hello.txt') return?

    02.Why call buffer.getvalue() after the ZipFile with block?

    03.Should an application extract every entry from an untrusted ZIP without a policy?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Using text methods on bytes without decoding

    COMMON MISTAKE
    from io import BytesIO
    from zipfile import ZipFile
    b=BytesIO()
    with ZipFile(b,'w') as z: z.writestr('x.txt', b'one two')
    with ZipFile(BytesIO(b.getvalue())) as z: print(z.read('x.txt').split(' '))
    FIX
    from io import BytesIO
    from zipfile import ZipFile
    b=BytesIO()
    with ZipFile(b,'w') as z: z.writestr('x.txt', b'one two')
    with ZipFile(BytesIO(b.getvalue())) as z: print(z.read('x.txt').decode('utf-8').split(' '))

    What happens: TypeError because bytes.split expects a bytes separator, not str.
    Decode when converting stored bytes back to text.

    Allowing path-like names in a simple archive

    COMMON MISTAKE
    archive.writestr('../notes.txt', b'hello')
    FIX
    name='notes.txt'
    assert '/' not in name and '\\' not in name
    archive.writestr(name, b'hello')

    What happens: The entry name describes a relative parent path that is inappropriate for this basename-only exercise.
    Validate names before archive creation; extraction also needs a separate policy.

    Browse this lesson’s problem fixes

    Sources and further reading