Extract contract data from PDFs in Python: parties, dates, and key clauses
Direct answer: Use pdfmux with extract_fields() to pull structured header data (parties, dates, governing law) from contract PDFs, and a Markdown-based clause parser for section-level text like termination and renewal terms. Contracts are longer and more varied in structure than invoices or forms, so the reliable pattern is: extract header fields with a schema, extract the full document as Markdown for clause-level search, then route anything below a confidence threshold to human review before it touches a contract management system. Install: pip install pdfmux.
Why contract extraction is a different problem than invoice extraction
Invoices and forms have a small, fairly stable set of fields in a fairly stable place on the page. Contracts don’t. A single template can run 5 pages or 50. The information you actually need — who the parties are, when it starts, when it renews, what triggers termination, what the liability cap is — is usually written as prose inside numbered sections, not laid out in a table or form field.
That changes the extraction strategy in three ways:
- Header fields (parties, effective date, contract type, governing law) are usually extractable with a schema, the same way invoice fields are, because they cluster near the top of page one.
- Clause-level content (termination, renewal, indemnification, liability) has to be found by section heading and pulled as text, not as a discrete field, because the actual content is a paragraph, not a value.
- Confidence has to be treated more conservatively. Getting an invoice total wrong by a rounding error is a nuisance. Missing an auto-renewal clause or an indemnification cap is a legal and financial exposure. The review threshold for contract extraction should be set higher than for invoices, and clause-level extraction should default to “surface for a human to confirm” rather than “auto-populate a system of record.”
Extracting header fields
Contract header data — parties, dates, contract type — behaves like any other schema-driven extraction:
from pdfmux import extract_fields
CONTRACT_HEADER_SCHEMA = {
"party_a": str,
"party_b": str,
"effective_date": str,
"contract_type": str,
"governing_law": str,
"term_length": str,
}
result = extract_fields("msa-2026-07.pdf", schema=CONTRACT_HEADER_SCHEMA)
print(result.fields)
# {
# "party_a": "Acme Logistics FZE",
# "party_b": "Northwind Freight Ltd",
# "effective_date": "2026-07-01",
# "contract_type": "Master Services Agreement",
# "governing_law": "Dubai, UAE (DIFC Courts)",
# "term_length": "24 months, auto-renewing"
# }
print(result.confidence) # e.g. 0.88
For multi-party agreements (more than two signatories) or amendments/addenda that reference a base contract, extend the schema with a parties: list[str] field and a references_contract: str field, then validate that any referenced contract ID actually exists in your document store before treating the amendment as standalone.
Full-document extraction for clause search
Header fields cover the “who and when.” For the “what happens if,” you need the full document text with section structure preserved, so you can search by heading:
from pdfmux import process
import re
result = process("msa-2026-07.pdf", quality="high")
full_text = result.text
def find_section(markdown_text: str, heading_keywords: list[str]) -> str | None:
"""Find a numbered/headed section by keyword match on its heading line."""
lines = markdown_text.splitlines()
pattern = re.compile(
r"^#{1,4}\s*(?:\d+\.?\d*\s*)?(.+)$|^\*\*(?:\d+\.?\d*\s*)?(.+?)\*\*$"
)
for i, line in enumerate(lines):
match = pattern.match(line.strip())
if not match:
continue
heading = (match.group(1) or match.group(2) or "").lower()
if any(kw in heading for kw in heading_keywords):
# Collect until the next heading line
section_lines = [line]
for next_line in lines[i + 1:]:
if pattern.match(next_line.strip()):
break
section_lines.append(next_line)
return "\n".join(section_lines).strip()
return None
termination_clause = find_section(full_text, ["termination", "term and termination"])
renewal_clause = find_section(full_text, ["renewal", "auto-renew", "renewal term"])
liability_clause = find_section(full_text, ["limitation of liability", "liability cap"])
This works because pdfmux preserves heading structure (Markdown #/##/##3-style prefixes or bold section labels, depending on the source template) rather than flattening the document into a single text blob. Section-boundary detection is heading-pattern matching, not a legal-NLP model — it will miss clauses that use unconventional headings (all-caps run-in headings with no numbering, for example), which is exactly the kind of case that should fall into manual review rather than being silently skipped.
Handling scanned and image-based contracts
Older contracts, faxed amendments, and wet-signed pages scanned back in are common in contract repositories. Force high-quality extraction when you know a document is scan-derived:
from pdfmux import process
result = process(
"scanned-amendment-2019.pdf",
quality="high",
)
print(result.confidence) # lower than a digital contract — treat conservatively
print(result.warnings) # e.g. ["Page 2: scanned image, applied RapidOCR"]
Signature pages are a particular failure mode: handwritten signatures, stamps, and date fields written by hand often produce low per-page confidence even when the surrounding contract text extracts cleanly. Don’t let a low-confidence signature page drag down your view of the whole document’s extraction quality — score and route pages independently rather than averaging confidence across the full document.
Confidence-based review routing
Because clause-level misses carry more downside than a typo in an invoice field, contract extraction pipelines should route conservatively:
from pdfmux import extract_fields, process
from dataclasses import dataclass, field
CONTRACT_HEADER_SCHEMA = {
"party_a": str,
"party_b": str,
"effective_date": str,
"contract_type": str,
}
# Higher bar than a typical invoice pipeline — contracts warrant more scrutiny
CONFIDENCE_AUTO = 0.90
CONFIDENCE_REVIEW = 0.75
@dataclass
class ContractExtraction:
file: str
header: dict
header_confidence: float
clauses_found: list = field(default_factory=list)
clauses_missing: list = field(default_factory=list)
status: str = "pending"
def process_contract(path: str, required_clauses: dict[str, list[str]]) -> ContractExtraction:
header_result = extract_fields(path, schema=CONTRACT_HEADER_SCHEMA)
full_result = process(path, quality="high")
found, missing = [], []
for clause_name, keywords in required_clauses.items():
section = find_section(full_result.text, keywords)
(found if section else missing).append(clause_name)
extraction = ContractExtraction(
file=path,
header=header_result.fields,
header_confidence=header_result.confidence,
clauses_found=found,
clauses_missing=missing,
)
if header_result.confidence >= CONFIDENCE_AUTO and not missing:
extraction.status = "auto_indexed"
elif header_result.confidence >= CONFIDENCE_REVIEW:
extraction.status = "review_recommended"
else:
extraction.status = "manual_review_required"
return extraction
REQUIRED_CLAUSES = {
"termination": ["termination", "term and termination"],
"renewal": ["renewal", "auto-renew"],
"liability": ["limitation of liability", "liability cap"],
"indemnification": ["indemnification", "indemnity"],
}
# result = process_contract("msa-2026-07.pdf", REQUIRED_CLAUSES)
The key design choice here: a missing required clause forces review_recommended even if header confidence is high, because a clean extraction of the wrong scope (a contract where termination terms genuinely weren’t found) is a different failure mode than a low-confidence extraction, and both need a human, just for different reasons.
Batch processing a contract repository
Running this across an existing contract archive to build a searchable index:
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
import json
contract_dir = Path("contracts/")
contract_files = list(contract_dir.glob("*.pdf"))
results = []
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {
executor.submit(process_contract, str(f), REQUIRED_CLAUSES): f
for f in contract_files
}
for future in as_completed(futures):
results.append(future.result())
auto_indexed = [r for r in results if r.status == "auto_indexed"]
needs_review = [r for r in results if r.status != "auto_indexed"]
print(f"Auto-indexed: {len(auto_indexed)}")
print(f"Needs review: {len(needs_review)}")
with open("contract_index.json", "w") as f:
json.dump([vars(r) for r in auto_indexed], f, indent=2, default=str)
Keep the needs_review list as its own queue with the extracted header and clause-found/missing state attached — a reviewer confirming or correcting a pre-filled extraction is meaningfully faster than a reviewer starting from a blank contract.
Building a searchable clause index
Once a batch of contracts is processed, the practical use case is usually “find every contract with an auto-renewal clause” or “find every contract governed by DIFC law” — a lightweight search index over the extracted fields and clause text:
import json
from pathlib import Path
def build_clause_index(indexed_contracts: list[dict]) -> dict:
index = {"by_governing_law": {}, "by_clause_present": {}}
for contract in indexed_contracts:
law = contract["header"].get("governing_law", "unknown")
index["by_governing_law"].setdefault(law, []).append(contract["file"])
for clause in contract["clauses_found"]:
index["by_clause_present"].setdefault(clause, []).append(contract["file"])
return index
with open("contract_index.json") as f:
contracts = json.load(f)
index = build_clause_index(contracts)
print(index["by_clause_present"].get("renewal", []))
For a full-text search layer on top of this (rather than just clause-presence lookup), pair the extracted Markdown with a vector store — see PDF extraction for RAG pipelines for the chunking and embedding pattern, and PDF chunking strategies for RAG for how to chunk long contract text without splitting a clause across chunk boundaries.
Integration with contract lifecycle management systems
Structured contract data maps to CLM systems similarly to how invoice data maps to AP systems:
- Ironclad: Workflow metadata fields via the API — map
party_a/party_bto counterparty records,effective_dateandterm_lengthto renewal-tracking fields - DocuSign CLM: Custom attribute fields on the agreement object
- ContractPodAi / native CLM: Most support a generic metadata import (CSV/JSON) for bulk-loading extracted fields against existing contract records
The clause-found/missing flags are worth surfacing directly in the CLM record even when they’re not native fields — a renewal_clause_detected: true/false custom attribute turns “which of our 400 legacy contracts auto-renew” from a manual review project into a filtered list.
FAQ
Can pdfmux tell me if a clause is favorable or risky?
No. pdfmux extracts and locates text — it does not interpret legal meaning or risk. Section-finding by heading keyword tells you a termination clause exists and where; whether its terms are favorable is a legal judgment, not an extraction task.
What confidence threshold should I use for contracts vs invoices?
Set it higher for contracts. The worked example above uses 0.90 for auto-index and 0.75 for the review-recommended tier, both stricter than typical invoice thresholds, because a missed clause carries more downside than a rounding error on a total.
How do I handle contracts with unconventional heading formats?
Heading-keyword section matching will miss clauses that use non-standard formatting (unnumbered all-caps run-in headings, for instance). Rather than trying to handle every format variant, treat a missing expected clause as a signal to route to manual review — that’s the safer failure mode.
Does this work on non-English contracts?
pdfmux’s routing includes Arabic-aware extraction (BiDi reordering, RTL handling) for Arabic-language documents. For other languages, extraction quality depends on the underlying backend’s language support — test against a sample before relying on it for a full archive.
For invoice-specific extraction, see Extract invoice data from PDFs in Python. For redacting sensitive data (SSNs, account numbers) once contract fields are extracted, see Detect and redact PII in PDFs with Python.