Extract PDF metadata in Python: document info, XMP, and what pipelines need
Direct answer: PDF metadata comes in two layers, and most tutorials only show you the first. The document layer is the /Info dictionary and XMP packet — title, author, creation date, producer — which you read with pypdf in about five lines. The extraction layer is what an AI pipeline actually needs: page count, whether a page has a real text layer or is a scan, and how much you can trust the text you pulled out. For that second layer, run the file through pdfmux with process("doc.pdf") and read result.page_count, result.confidence, and result.extractor_used. The document /Info tells you what the PDF claims about itself; the extraction metadata tells you what you can do with it. Production pipelines route on the second, not the first.
The two layers of PDF metadata
When people say “PDF metadata,” they usually mean one of two very different things:
- Document metadata — the descriptive fields embedded in the file: title, author, subject, keywords, the application that produced it, and creation/modification timestamps. This lives in the PDF’s
/Infodictionary and, in newer files, an XMP packet. It is self-reported and frequently wrong, stale, or empty. - Extraction metadata — facts you only learn by actually opening and processing the file: how many pages it really has, which pages carry a text layer versus a flattened scan, whether the document is encrypted, and how confident an extractor is in the text it returned.
A document might proudly declare Author: Microsoft® Word 2016 in its /Info dictionary while being a 40-page scanned contract with no text layer at all. The first layer lies; the second layer doesn’t. If you are building a RAG pipeline or any system that ingests PDFs at scale, you care far more about the second.
Layer 1: read the /Info dictionary with pypdf
The classic document metadata fields live in the /Info dictionary. pypdf exposes them as a simple object:
from pypdf import PdfReader
reader = PdfReader("report.pdf")
meta = reader.metadata
print("Title: ", meta.title)
print("Author: ", meta.author)
print("Subject: ", meta.subject)
print("Creator: ", meta.creator) # the authoring app
print("Producer: ", meta.producer) # the library that wrote the PDF
print("Created: ", meta.creation_date) # a datetime, or None
print("Modified: ", meta.modification_date)
print("Pages: ", len(reader.pages))
A few things bite people here:
- Every field is optional.
meta.titlereturnsNonefor a huge fraction of real-world PDFs. Never assume a field is present. - Dates are strings until parsed. Older
pypdfversions hand you the rawD:20260618143000+04'00'string; recent versions parsecreation_dateinto adatetime. Check your version and handle both. creatorvsproducerare different.creatoris the program a human used (Word, InDesign, Chrome’s print-to-PDF).produceris the library that serialized the bytes. A filecreator-ed by Word is oftenproducer-ed by a separate PDF engine.
Wrap it so missing fields never crash your loop:
def safe_doc_metadata(path: str) -> dict:
reader = PdfReader(path)
m = reader.metadata or {}
return {
"title": getattr(m, "title", None),
"author": getattr(m, "author", None),
"producer": getattr(m, "producer", None),
"created": str(getattr(m, "creation_date", None)),
"pages": len(reader.pages),
"encrypted": reader.is_encrypted,
}
Layer 1b: XMP, the metadata most code ignores
Since PDF 1.4, files can also carry an XMP packet — an XML block (Adobe’s Extensible Metadata Platform) that holds richer, namespaced metadata than the flat /Info dictionary. Design tools, document management systems, and many compliance workflows write XMP and leave /Info half-empty. If you only read /Info, you miss it.
from pypdf import PdfReader
reader = PdfReader("designed-brochure.pdf")
xmp = reader.xmp_metadata
if xmp:
print("XMP creator tool:", xmp.xmp_creator_tool)
print("Dublin Core title:", xmp.dc_title) # {'x-default': '...'}
print("Create date:", xmp.xmp_create_date)
else:
print("No XMP packet — fall back to /Info")
The Dublin Core (dc_*) fields are the ones worth checking: dc_title, dc_creator, dc_description, dc_subject. They follow a published standard rather than a vendor’s whim, so when they’re present they’re more trustworthy than /Info. The catch is the same as before: the packet is often absent, so always branch on xmp is None.
Why document metadata is not enough
Here is the failure that motivates the whole second layer. You ingest 10,000 PDFs, you read each one’s /Info title and creation date, you index them, and you feel productive. Then your downstream search returns garbage for a few hundred documents — because those few hundred were scans with no text layer, and your text extractor returned empty strings (or worse, OCR-able mojibake) that your metadata pass never inspected.
Document metadata is silent about the thing that breaks pipelines: is there actually extractable text, and is it any good? A PDF’s /Info dictionary has no field for “this page is a photograph of a page.” You have to open the content stream to find out. That is extraction metadata, and it is where pdfmux focuses.
Layer 2: extraction metadata with pdfmux
Install the toolkit and process the file. Beyond the converted text, the result object carries the operational metadata a pipeline routes on:
from pdfmux import process
result = process("contract.pdf", quality="standard")
print("Pages: ", result.page_count)
print("Confidence: ", f"{result.confidence:.0%}") # 0.0–1.0, how trustworthy the text is
print("Extractor: ", result.extractor_used) # which backend handled it
The two fields that change how you build:
result.confidenceis a 0.0–1.0 score derived from multiple signals — not a guess, but a measurable property of the extraction. A page that scores 0.45 is telling you the text is probably scrambled or incomplete. Most extractors give you no such signal; they return broken text with the same shrug as perfect text. pdfmux’s per-page confidence scoring is the field you gate on.result.extractor_usedrecords which engine actually ran. A born-digital page gets fast text extraction; a scanned page gets routed to OCR automatically by the self-healing pipeline. Reading this field tells you, after the fact, which of your 10,000 documents were scans — exactly the information/Infocould never give you.
Triage without a full extract
When you have thousands of files and only want to classify them — digital vs scanned, how many pages, is it encrypted — you don’t need the full text. pdfmux exposes a lightweight analyze_pdf path (also available as an MCP tool for AI agents) that returns this triage metadata cheaply, so you can sort your corpus before committing compute to extraction.
A combined metadata function
In practice you want one record per document that merges both layers — the descriptive /Info/XMP fields for display and the extraction fields for routing:
from pypdf import PdfReader
from pdfmux import process
def full_metadata(path: str) -> dict:
reader = PdfReader(path)
info = reader.metadata or {}
result = process(path, quality="standard")
return {
# Document layer — what the file claims
"title": getattr(info, "title", None),
"author": getattr(info, "author", None),
"producer": getattr(info, "producer", None),
"created": str(getattr(info, "creation_date", None)),
"encrypted": reader.is_encrypted,
# Extraction layer — what we can actually do
"page_count": result.page_count,
"confidence": round(result.confidence, 3),
"extractor": result.extractor_used,
"status": "ok" if result.confidence >= 0.80 else "review",
}
That status field is the payoff. Anything below 0.80 confidence gets flagged for review instead of silently poisoning your index. A pure /Info metadata pass can’t produce this column, because nothing in the document metadata knows whether the text came out clean.
Which layer answers which question
| Question | Layer | Where it lives | Reliable? |
|---|---|---|---|
| Who is the stated author? | Document | /Info Author or XMP dc_creator | Often empty/stale |
| When was it created? | Document | /Info CreationDate, XMP xmp_create_date | Usually present |
| How many pages? | Both | len(reader.pages) or result.page_count | Reliable |
| Is it encrypted? | Document | reader.is_encrypted | Reliable |
| Is this page a scan? | Extraction | result.extractor_used per page | Reliable |
| Can I trust the text? | Extraction | result.confidence | Reliable |
| What app produced it? | Document | /Info Producer/Creator | Self-reported |
Common pitfalls
- Treating empty as an error. A missing
/Infotitle is normal, not a failure. Default it and move on. - Trusting the page count from
/Info. Some fields claim a/Pagescount that disagrees with the real tree after a merge or split. Countlen(reader.pages)(orresult.page_count) instead of trusting a declared value. - Reading metadata on an encrypted file. If
reader.is_encryptedisTrue, you may need to callreader.decrypt("")(empty owner password is common) before fields populate. See extracting text from encrypted PDFs for the full handling. - Skipping XMP. If a file’s
/Infolooks empty, checkreader.xmp_metadatabefore concluding the document has no metadata — it may all be in the XMP packet.
The rule of thumb
Read the /Info dictionary and XMP when you need to describe a document — show its title in a UI, sort by author, display a created date. Read extraction metadata when you need to process one — decide whether a page is a scan, whether to trust its text, or whether to route it to review. The first layer is a label on the box. The second is what’s actually inside. Pipelines that only read the label are the ones that quietly break.
Once you have the metadata, the natural next step is the content itself — see the best PDF extraction library for Python for choosing the right extractor for your documents.