skip to content

How do you write a custom tarfile extraction filter on top of tarfile.data_filter?

level: seniorimportance: nice to knowfreq 12%

answer

  1. It is just a callable, not a class
  2. Two arguments in, one member or nothing out
  3. Three outcomes: extract, skip, abort
  4. Delegate to the strict built-in first
  5. TarInfo.replace instead of mutating

basics

~20 s

A filter is any callable taking the TarInfo member and the destination path. Return a member to extract it, return None to skip it, or raise to abort the whole extraction. Call tarfile.data_filter first to keep the standard checks, then add your own policy.

solid answer

~40 s

The `filter` argument of `TarFile.extractall()` accepts a callable with the signature `(member: TarInfo, dest_path: str) -> TarInfo | None`. Returning the member — optionally a modified copy from `TarInfo.replace()`, since you should not mutate the original in place — extracts it; returning `None` skips that member and continues; raising aborts the extraction. The idiom is to **delegate first**: call `tarfile.data_filter(member, dest_path)` so every standard containment, link, special-file and metadata check still runs, then layer policy on the result — allow only regular files and directories, cap `member.size`, allowlist extensions, or rename. Attach it per call with `filter=fn`, or per archive with `TarFile.extraction_filter`; `shutil.unpack_archive()` forwards a `filter` too. The filter sees metadata only, never content.

code

python · 32 lines
python
import io
import os
import tarfile
import tempfile

MAX_MEMBER = 8 * 1024 * 1024


def image_filter(member: tarfile.TarInfo, dest: str) -> tarfile.TarInfo | None:
    member = tarfile.data_filter(member, dest)  # keep every standard check
    if member.issym() or member.islnk():
        return None  # skip links this service has no use for
    if member.isreg() and member.size > MAX_MEMBER:
        raise ValueError(f"member too large: {member.name}")
    return member.replace(mode=0o600, deep=False)


buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w") as tar:
    png = b"\x89PNG\r\n\x1a\n"
    info = tarfile.TarInfo("shots/a.png")
    info.size = len(png)
    tar.addfile(info, io.BytesIO(png))
    link = tarfile.TarInfo("shots/latest.png")
    link.type = tarfile.SYMTYPE
    link.linkname = "a.png"
    tar.addfile(link)

buf.seek(0)
with tempfile.TemporaryDirectory() as dest, tarfile.open(fileobj=buf) as tar:
    tar.extractall(dest, filter=image_filter)
    print("extracted:", sorted(os.listdir(os.path.join(dest, "shots"))))

go deeper

for a junior

Know that the filter argument takes the built-in names 'data', 'tar' and 'fully_trusted', and that a callable is possible. Reaching for a custom one before you can explain what 'data' already covers is the wrong order.

for a middle

Be able to state the signature and the three outcomes - return a member, return None to skip, raise to abort - and to say why the first line of a custom filter should delegate to tarfile.data_filter rather than reimplement its checks.

for a senior

Show judgement about what belongs in the filter and what does not: metadata policy inside it, content validation after extraction in a scratch directory, total budgets in stateful code around it. Mention TarInfo.replace over mutation and where the filter gets attached.

for a principal

Decide where the policy lives at all - one extraction helper owning the filter for every service that ingests archives, versus a per-call argument - and weigh a custom filter against simply refusing archive uploads and taking individual files instead.

## The contract PEP 706's `filter` argument is not limited to the three built-in names. It also accepts a callable, and the contract is small enough to recite: ``` filter(member: tarfile.TarInfo, dest_path: str) -> tarfile.TarInfo | None ``` It is called **once per member, before that member is extracted**, with the member's metadata and the destination directory. Three outcomes: * **Return a `TarInfo`** — extract it, using the returned object's attributes. It need not be the object you were handed. * **Return `None`** — skip this member silently and carry on with the rest of the archive. * **Raise** — abort. Raising a `tarfile.FilterError` subclass keeps you consistent with the built-in policies, but any exception propagates out of `extractall()`. That three-way outcome is the part candidates get wrong: returning `False` does not skip anything (a `TarInfo` is truthy, `False` is not a `TarInfo`), and `None` does not abort. ## Delegate, then decide A custom filter should almost never start from scratch. `tarfile.data_filter` encodes checks that took the core team a PEP and a security backport to get right: separator stripping, resolution of the target under the destination, absolute and escaping link targets, refusal of device and FIFO members, clearing of ownership, and mode normalisation. Call it first and build on what it returns: ```python def image_filter(member, dest): member = tarfile.data_filter(member, dest) # every standard check still runs if member.issym() or member.islnk(): return None # skip links data would have allowed if member.isreg() and member.size > MAX_MEMBER: raise ValueError(f"member too large: {member.name}") return member.replace(mode=0o600, deep=False) ``` Note what the delegation buys: `data_filter` already refused an absolute link target, so the `issym()` branch is tightening policy (this service wants no links at all, not even contained ones) rather than fixing a hole. ## Modify with `replace`, not by mutation `TarInfo.replace()`, added alongside the filters in 3.12, returns a copy with the named attributes changed — `name`, `mode`, `mtime`, `linkname`, `uid`, `gid`, `uname`, `gname` — and takes a `deep` keyword controlling whether nested structures are copied too. Prefer it to assigning attributes on the member you were given: the same `TarInfo` objects are reachable through `TarFile.getmembers()`, and mutating them makes the archive object disagree with what was extracted, which is a genuinely confusing thing to debug. Passing `deep=False` is the cheap path when you only touch scalar fields. ## Where the filter is attached Three places, in increasing scope: * `tar.extractall(dest, filter=image_filter)` — per call, and the clearest to read. * `tar.extraction_filter = image_filter` — per `TarFile` instance, useful when the extraction call is inside a helper you do not control. * `tarfile.TarFile.extraction_filter = staticmethod(image_filter)` — process-wide default. The `staticmethod` wrapper matters, because a bare function assigned to a class attribute would be bound as a method and receive the `TarFile` as its first argument. `shutil.unpack_archive()` also grew a keyword-only `filter` in 3.12 that it forwards to `tarfile`, so a helper built on it is not stuck with the default. ## What a filter can and cannot do It sees **metadata, not content**. There is no hook for "reject this member if its first bytes are not a valid image header" — that check belongs after extraction, on the file, in your scratch directory. It also runs per member with no state of its own, so a *total* budget across the archive needs a closure or a small callable object holding the running sum; and even then the filter only sees each member's declared size rather than bytes written. For tar that declared size is what extraction actually reads, so a per-member `member.size` check is a real bound — but the running total still has to be yours. Two smaller traps. Skipping a directory member does not skip its children; each member is filtered independently, and extraction will create missing parent directories with platform defaults. And a filter that raises leaves the destination partly populated, which is the same reason the built-in policies do: extract into a temporary directory and promote it only when the call returns normally. ## When not to write one A custom filter is worth reaching for when your policy is genuinely narrower than `data` and can be decided from metadata: this service accepts only regular files, only under a known prefix, only below a size, with no links of any kind. It is the wrong tool when the rule depends on content, on totals across the archive, or on anything the extractor cannot see. And it is worth asking whether the archive needs unpacking at all: reading members as streams through `TarFile.extractfile()` and never writing them to disk removes every path question at once, which is often the better answer for a service that only wants to look at what arrived.

  • What should a custom filter return to skip a member without stopping the extraction?
    None. Returning a TarInfo extracts that member, returning None drops it and extraction continues with the next one, and raising aborts the whole call. Returning False or True is a bug: neither is a TarInfo, and the extractor will not treat them as a skip signal. If you want the run to stop, raise a tarfile.FilterError subclass so callers can catch the same type the built-in policies raise.
  • Can a filter enforce a total size budget for the whole archive?
    Only if you give it state. The callable is invoked per member with no memory, so hold the running sum in a closure or a small callable object and add member.size on each accepted member. For tar that declared size is what extraction really reads, so it is a genuine bound; even so, a belt-and-braces total counted from bytes written stays worthwhile when the archive arrives compressed.
  • Why call tarfile.data_filter inside your filter instead of writing the checks yourself?
    Because those checks are subtle and were hardened over a PEP and a round of security backports: separator stripping, resolving the target under the destination, symlink targets resolved relative to the member's own directory, refusal of device and FIFO members, and clearing of ownership. Reimplementing them invites the exact bug the filters exist to close. Delegate first, then tighten.

saying these in an interview costs you the question

  • Replaces data_filter rather than calling it first
  • Returns True or False from the filter callable
  • Thinks returning None aborts the whole extraction
  • Mutates the TarInfo in place instead of using replace
  • Assumes the filter can inspect member contents
  • Believes a per-member cap bounds the archive total

context