filetype
filetype tells you what a file actually is by reading its magic number, the fixed byte signature at the start of the file, instead of trusting the extension or the Content-Type header a client sent you. It is a straight port of the Go library of the same name, it is pure Python with zero dependencies and no C extensions, and it only ever looks at the first 8192 bytes, so you can hand it a path, a bytes buffer, a memoryview or an open file object and it will not read a two gigabyte upload into memory. It recognises around a hundred binary formats across images, video, audio, archives, Office and OpenDocument files, fonts and wasm, and returns an object with .mime and .extension or None when nothing matches.
The right first line of defence for binary uploads when you want zero dependencies and a two-line integration, and it does that job well. Just do not mistake a hundred magic numbers for real content inspection, and be aware that the last release predates the last commit by more than two years.
Use it if
- You accept file uploads and need to reject a .jpg that is really a zip or an executable; the browser-supplied filename and Content-Type are both attacker-controlled and this is the cheap server-side check
- You want no C dependency: python-magic needs libmagic installed on the host, which turns a pip install into a Dockerfile change and a Windows headache, and filetype has literally no dependencies
- You are working from a stream or an S3 range request and only have the first few kilobytes: pass those bytes directly and get an answer without downloading the rest
- You need to sort a pile of files into images, video, audio, archives, documents or fonts, which the is_image / is_video / is_document helpers do in one call
- You need text formats: CSV, JSON, XML, HTML, SVG, YAML, source code and plain text have no magic number, so filetype returns None for all of them. If your uploads are mostly text this library answers nothing
- You need breadth: libmagic recognises thousands of formats with encoding and charset detection, filetype knows roughly a hundred. python-magic wraps libmagic and is the honest choice when coverage matters more than install simplicity
- You cannot live with a stalled release: 1.2.0 shipped in November 2022 and nothing has been published since, even though commits landed on master as recently as May 2025. Merged fixes are sitting unreleased, and 53 open issues (64 counting PRs) are waiting
- You are relying on Office document detection: docx, xlsx and pptx are identified by walking zip entry names inside the first 6000 bytes, and files written by tools that order zip entries differently fall through and are reported as plain zip
- You want a security boundary rather than a hint: a matcher that checks a handful of header bytes can be satisfied by a polyglot file that is a valid PNG header followed by something else. Use it to reject obvious mismatches, not as your only defence
Setup reality
pip install filetype and you are done, which is genuinely the whole point of picking it over python-magic. Two things in the README are worth correcting before you design around them. It says only the first 261 bytes are needed, but the code sets _NUM_SIGNATURE_BYTES to 8192 and reads that much, because zip-based formats like docx and epub need to see entry names further in; budget 8KB, not 261 bytes, when fetching a range from object storage. It also does not declare requires_python and its classifiers stop at Python 3.9, so pip will happily install it on 3.13 where it does work, but you are outside anything the metadata promises. One more sharp edge: filetype.add_type() mutates a module-level list, so a custom matcher registered anywhere in your process affects every other caller, including library code you did not write.
Patterns
Identify a file on diskguess-file-type
import filetype
kind = filetype.guess("uploads/photo.bin")
if kind is None:
print("unknown or text-based file")
else:
print(kind.extension) # 'jpg'
print(kind.mime) # 'image/jpeg'None means either an unrecognised binary or any text format, and the library cannot tell you which. A missing path raises FileNotFoundError rather than returning None, so guard the path first.
Identify from a bytes buffer you already holdguess-from-bytes
import filetype
header = response.content[:8192]
kind = filetype.guess(header)
print(filetype.guess_mime(header))
print(filetype.guess_extension(header))8192 is the real limit the library reads, despite the README's 261 bytes. Passing fewer bytes than that makes zip-based formats such as docx and epub fall back to a plain zip match.
Reject an upload whose real type does not match its claimvalidate-upload
import filetype
ALLOWED = {"image/jpeg", "image/png", "image/webp"}
def check(upload_file):
kind = filetype.guess(upload_file.file) # file-like object
if kind is None or kind.mime not in ALLOWED:
raise ValueError(f"rejected: detected {kind.mime if kind else 'unknown'}")
return kind.mimeNever trust upload_file.content_type; the client sets it. Passing the file object works because filetype records tell(), seeks to 0, reads 8192 bytes and restores the position, so your later read still starts where you left it.
Ask what family a file belongs tocheck-file-category
import filetype
if filetype.is_image(buf):
store_thumbnail(buf)
elif filetype.is_video(buf):
queue_transcode(buf)
elif filetype.is_archive(buf):
reject("archives not allowed")
elif filetype.is_document(buf):
queue_text_extraction(buf)The helpers are is_image, is_video, is_audio, is_archive, is_font and is_document. There is no is_application helper even though wasm is in the type table; use filetype.application_match(buf) for that group.
Only run the matchers you care aboutmatch-within-one-group
from filetype import image_match, video_match, match
from filetype.types import IMAGE
kind = image_match(buf) # skips ~80 non-image matchers
# or pass an explicit matcher list
kind = match(buf, matchers=IMAGE)match() walks the type list in order and returns the first hit, so restricting the list is both faster and safer: it removes any chance of a stray archive or font signature winning on a crafted buffer.
Register a signature the library does not knowadd-custom-matcher
import filetype
from filetype.types import Type
class Parquet(Type):
MIME = "application/vnd.apache.parquet"
EXTENSION = "parquet"
def __init__(self):
super().__init__(mime=Parquet.MIME, extension=Parquet.EXTENSION)
def match(self, buf):
return len(buf) > 3 and buf[:4] == bytearray([0x50, 0x41, 0x52, 0x31])
filetype.add_type(Parquet())
print(filetype.guess_extension(parquet_bytes)) # 'parquet'add_type inserts at position 0 of a module-level list, so your matcher runs before every built-in and the change is process-wide. Registering the same type twice on a hot reload silently stacks duplicates.
Find a type entry by MIME or extensionlookup-type-by-mime
import filetype
kind = filetype.get_type(mime="image/avif")
print(kind.extension if kind else "unsupported")
print(filetype.is_mime_supported("video/webm")) # True
print(filetype.is_extension_supported("svg")) # False, SVG is textget_type matches on either argument with an or, so passing a mime that does not exist together with an extension that does still returns a hit. Pass one argument at a time if you want a strict lookup.
Enumerate everything the library can detectlist-supported-types
import filetype
for kind in filetype.types:
print(kind.extension, kind.mime)
print(len(filetype.types))This is the honest way to decide whether the library covers your inputs, and it takes ten seconds. Run it before assuming support for anything text-based, since the list is entirely binary formats.
Identify a remote object without downloading itdetect-from-remote-range
import boto3, filetype
s3 = boto3.client("s3")
head = s3.get_object(Bucket="uploads", Key=key, Range="bytes=0-8191")["Body"].read()
kind = filetype.guess(head)
print(kind.mime if kind else "unknown")Request 0-8191, not 0-260: the shorter range is what the README suggests and it breaks Office and OpenDocument detection. This costs one small GET instead of pulling a multi-gigabyte object through your process.
Understand why docx sometimes reports as ziphandle-office-documents
import filetype, zipfile, io
kind = filetype.guess(buf)
if kind and kind.extension == "zip":
# possible OOXML that the entry-order heuristic missed
with zipfile.ZipFile(io.BytesIO(buf)) as z:
names = set(z.namelist())
if "word/document.xml" in names:
kind_mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"The OOXML matcher looks for [Content_Types].xml, _rels/.rels or docProps among the first few zip entries inside 6000 bytes. Writers that order entries differently, or that store the document deeper, produce a plain zip result and need the fallback above (which requires the whole file, not just the header).
Guard against empty and falsy inputempty-input-behaviour
import filetype
print(filetype.guess(b"")) # None, short-circuited before matching
print(filetype.guess(None)) # None
try:
filetype.guess(12345)
except TypeError as e:
print(e) # Unsupported type as file input: <class 'int'>guess() returns None for anything falsy without running a single matcher, so an empty upload looks the same as an unrecognised one. Check length yourself if zero-byte files need a distinct error.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| python-magic | PyPI | You need libmagic's thousands of signatures, text format detection or charset guessing and can install a system library. |
| puremagic | PyPI | You want dependency-free detection like filetype but a larger signature table and confidence-ranked results. |
| magika | PyPI | You need text and source-code formats identified too and can afford a bundled model rather than byte signatures. |