Desk Clarity

A field guide to everyday digital problems.

Developer tools / Practical guide

ZIP Lists Two Files but Reads the Same Bytes: Select Each ZipInfo Entry

Read each duplicate ZIP member by its ZipInfo identity, or reject ambiguous names before importing a report.

By ON Lab5 min readSources checked November 10, 2026 (KST)

A ZIP import can list two entries named report.txt while a loop appears to read the newer contents twice. Before treating this as a damaged download, inspect what the loop passes to read(). A filename string identifies a name lookup. The individual ZipInfo objects returned by infolist() let the reader select each listed entry, including entries whose names are equal.

The filename is not the entry identity

Suppose a producer writes an initial report and later adds another report under the same member name. Your application may require one report, but that requirement does not automatically become an archive constraint. Converting the member list into a dictionary keyed by filename also discards the distinction your import needs.

Use two separate questions during diagnosis: how many entries match the expected name, and which entry does the consumer mean? If the consumer wants every version, preserve the individual entry objects. If the consumer requires exactly one file, reject the ambiguity before processing any matching payload. These are different application contracts; silently choosing an entry hides the decision.

Reproduce the difference without touching a real archive

The following fixture stays in memory. It writes two small, independently stored members with the same name, plus one uniquely named member. It deliberately captures the duplicate-name warning instead of changing the warning policy for the rest of an application. Run the complete block in a Python interpreter; it creates no files and extracts nothing.

import io
import warnings
import zipfile

buffer = io.BytesIO()
with warnings.catch_warnings(record=True) as notices:
    warnings.simplefilter('always')
    with zipfile.ZipFile(buffer, 'w') as archive:
        archive.writestr('report.txt', b'old\n')
        archive.writestr('report.txt', b'new\n')
        archive.writestr('unique.txt', b'only\n')

with zipfile.ZipFile(io.BytesIO(buffer.getvalue())) as archive:
    matches = [item for item in archive.infolist()
               if item.filename == 'report.txt']
    by_entry = [archive.read(item) for item in matches]
    by_name = [archive.read(item.filename) for item in matches]
    assert by_entry == [b'old\n', b'new\n']
    assert by_name == [b'new\n', b'new\n']
    assert len({item.header_offset for item in matches}) == 2
    print('entry reads:', by_entry)
    print('name reads:', by_name)
    print('warnings:', [str(item.message) for item in notices])

The first result contains old and new bytes. The second contains new bytes twice in this fixture. The loop still visits two objects in both cases; replacing each object with its filename changes how the payload is selected. Keep the first loop when you need every matching member. For streaming processing, archive.open(item) can take that same object; keep the archive and member handles open while consuming the stream.

The observed name lookup is useful for explaining this fixture, but an import should not rely on a producer's duplicate ordering to decide which report is authoritative. Names such as report-v1.txt and report-v2.txt, or a validated manifest that explicitly describes versions, make that producer contract easier to inspect. Changing a loop alone cannot establish which business record is correct.

Require exactly one matching report before reading it

This second complete example enforces a consumer contract. It checks the number of matches first and returns bytes only when the required name is unique. It exercises duplicate, missing and unique inputs so the rejection branch is visible instead of being left as an untested suggestion.

import io
import warnings
import zipfile


def read_exactly_one(archive, name):
    matches = [item for item in archive.infolist()
               if item.filename == name]
    if len(matches) != 1:
        raise ValueError(f'Expected one {name!r}; found {len(matches)}')
    return archive.read(matches[0])


buffer = io.BytesIO()
with warnings.catch_warnings():
    warnings.simplefilter('ignore', UserWarning)
    with zipfile.ZipFile(buffer, 'w') as archive:
        archive.writestr('report.txt', b'old\n')
        archive.writestr('report.txt', b'new\n')
        archive.writestr('unique.txt', b'only\n')

with zipfile.ZipFile(io.BytesIO(buffer.getvalue())) as archive:
    rejected = []
    for name in ['report.txt', 'missing.txt']:
        try:
            read_exactly_one(archive, name)
        except ValueError as error:
            rejected.append(name)
            print(error)
    assert rejected == ['report.txt', 'missing.txt']
    assert read_exactly_one(archive, 'unique.txt') == b'only\n'
    assert archive.testzip() is None
    print('Unique member accepted; duplicate and missing rejected.')

The integrity check succeeds even though the application rejects report.txt. That is the distinction this fixture demonstrates: readable payloads do not establish that a required name appears exactly once. For a multi-file import, validate the complete required-name contract before applying writes to a database or destination directory. Otherwise a later ambiguous member could be discovered after earlier records were already committed.

The helper uses exact string equality. It does not merge case variants, normalize Unicode names, enforce path rules or implement an extraction policy. If your destination treats two different archive names as equivalent, define and validate that additional rule separately. Do not describe this helper as a complete validator for untrusted uploads.

Keep the diagnosis within the tested boundary

These examples use tiny, trusted fixtures and were executed on Python 3.11, 3.13 and 3.14. They cover different local-header offsets, member selection and the consumer's uniqueness check. They do not test encrypted, malformed, overlapping or large archives. Reading all bytes is convenient here; large external inputs need their own resource limits.

A historical CPython request from 2007 documents the need to read duplicate filenames individually. It supports the reader problem, not a claim about present search volume. A separate 2024 report concerns entries referring to the same local header and overlap handling. The distinct-header fixture above does not reproduce that report, and selecting a ZipInfo is not presented here as a workaround for an overlap error.

If the real symptom is an unexpectedly large ZIP, inspect the input set and output location instead; the ZIP size and self-inclusion guide covers that separate problem. For repeated names, preserve entry identity, state whether the consumer accepts several matches, and verify that policy before acting on the payload.

Sources & further reading