Building a resume parser with structured extraction
Resumes are a canonical example of semi-structured data: they follow loose conventions, but every candidate formats theirs differently. Traditional rule-based parsers break on anything outside their template library. LLMs handle the variability naturally — they understand that "Software Engineer II at Google, 2019–2022" and "Senior SWE | Google LLC | Jan 2019 to Mar 2022" contain the same information — but without structure enforcement they return free-form text that is hard to query.
The solution is structured extraction: use the LLM for understanding and interpretation, but force the output into a typed schema from the start.
Designing the extraction schema
Before writing any code, spend time on the schema. A schema designed for search and filtering looks different from one designed for display. For a recruiting pipeline you typically want:
from pydantic import BaseModel, Field
from typing import Optional
from enum import Enum
class EmploymentType(str, Enum):
FULL_TIME = "full_time"
PART_TIME = "part_time"
CONTRACT = "contract"
INTERNSHIP = "internship"
FREELANCE = "freelance"
class WorkExperience(BaseModel):
company: str
title: str
employment_type: Optional[EmploymentType] = None
start_year: int
start_month: Optional[int] = None
end_year: Optional[int] = None # None means current
end_month: Optional[int] = None
description: Optional[str] = None
technologies: list[str] = Field(default_factory=list)
class Education(BaseModel):
institution: str
degree: Optional[str] = None # "Bachelor of Science", "PhD"
field_of_study: Optional[str] = None
graduation_year: Optional[int] = None
gpa: Optional[float] = None
class Candidate(BaseModel):
full_name: str
email: Optional[str] = None
phone: Optional[str] = None
location: Optional[str] = None
linkedin_url: Optional[str] = None
github_url: Optional[str] = None
summary: Optional[str] = None
total_years_experience: Optional[float] = None
work_experience: list[WorkExperience] = Field(default_factory=list)
education: list[Education] = Field(default_factory=list)
skills: list[str] = Field(default_factory=list)
languages: list[str] = Field(default_factory=list)
extraction_confidence: float = Field(ge=0.0, le=1.0)
extraction_notes: list[str] = Field(default_factory=list)
Notice the extraction_confidence and extraction_notes fields. These are not candidate data — they are metadata about the extraction itself. Asking the model to self-report uncertainty is one of the most practical improvements you can make to a parsing pipeline.
The extraction function
Use tool use / structured output mode rather than asking the model to return JSON in a text response. Tool use enforces schema conformance at the API level:
import json
from anthropic import Anthropic
from pydantic import ValidationError
client = Anthropic()
CANDIDATE_SCHEMA = Candidate.model_json_schema()
def parse_resume(resume_text: str) -> Candidate:
"""
Extract structured candidate data from raw resume text.
Raises ValidationError if the model returns an invalid schema.
"""
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=2048,
system="""You are a resume parser. Extract structured information from
the resume text provided. Follow these rules:
- Normalise dates: always extract year as integer, month as 1-12 integer
- For current positions, leave end_year and end_month as null
- Calculate total_years_experience from the work history (not self-reported)
- Extract only skills explicitly mentioned, do not infer
- Set extraction_confidence between 0 and 1 based on how clear the resume is
(1.0 = very clear and complete, 0.3 = ambiguous or poorly formatted)
- Use extraction_notes to flag anything ambiguous or that required guessing""",
messages=[
{
"role": "user",
"content": f"Parse this resume:\n\n{resume_text}"
}
],
tools=[{
"name": "extract_candidate",
"description": "Extract structured candidate information from a resume",
"input_schema": CANDIDATE_SCHEMA
}],
tool_choice={"type": "tool", "name": "extract_candidate"}
)
tool_use = next(b for b in response.content if b.type == "tool_use")
data = tool_use.input
# Pydantic validates the schema and coerces types
return Candidate(**data)
Handling format variability
Resumes arrive in many formats: plain text extracted from PDFs, HTML from LinkedIn exports, DOCX converted to text. Each has its own artefacts that interfere with extraction.
import re
def preprocess_resume(raw_text: str) -> str:
"""
Clean common artefacts from automated text extraction before sending
to the LLM. Reduces token usage and improves extraction accuracy.
"""
# Remove excessive whitespace from PDF column extraction
text = re.sub(r'\n{3,}', '\n\n', raw_text)
text = re.sub(r' {2,}', ' ', text)
# Remove common PDF header/footer noise
text = re.sub(r'Page \d+ of \d+', '', text, flags=re.IGNORECASE)
text = re.sub(r'Confidential', '', text, flags=re.IGNORECASE)
# Normalise bullet characters (PDF extraction often mangles these)
for bullet in ['•', '◦', '▪', '▸', '●', '○']:
text = text.replace(bullet, '-')
# Enforce a maximum length to control costs
# Most resumes are well under 4000 words; anything longer is unusual
words = text.split()
if len(words) > 4000:
text = ' '.join(words[:4000])
text += '\n[TRUNCATED]'
return text.strip()
For PDFs specifically, the text layer quality varies enormously by how the PDF was created. A digitally-created PDF extracts cleanly; a scanned PDF often needs OCR. Tools like pdfplumber work well for digital PDFs; for scanned documents you need a separate OCR step before the LLM.
A production pipeline
A single-function parser works for prototyping but breaks under load. A production pipeline needs batching, error handling, and a way to queue retries:
import asyncio
from dataclasses import dataclass, field
from typing import Callable
@dataclass
class ParseResult:
resume_id: str
candidate: Candidate | None
error: str | None
raw_text: str
async def parse_resume_async(resume_id: str, raw_text: str) -> ParseResult:
"""Async wrapper for use in concurrent pipelines."""
try:
cleaned = preprocess_resume(raw_text)
candidate = parse_resume(cleaned)
return ParseResult(
resume_id=resume_id,
candidate=candidate,
error=None,
raw_text=raw_text
)
except Exception as e:
return ParseResult(
resume_id=resume_id,
candidate=None,
error=str(e),
raw_text=raw_text
)
async def process_batch(
resumes: list[tuple[str, str]], # (id, text) pairs
on_result: Callable[[ParseResult], None],
concurrency: int = 5
) -> list[ParseResult]:
"""
Process a batch of resumes with controlled concurrency.
The Anthropic API rate limits by requests per minute, so
concurrency=5 is a safe default for most tier levels.
"""
semaphore = asyncio.Semaphore(concurrency)
async def bounded_parse(resume_id: str, text: str) -> ParseResult:
async with semaphore:
result = await parse_resume_async(resume_id, text)
on_result(result)
return result
tasks = [bounded_parse(rid, text) for rid, text in resumes]
return await asyncio.gather(*tasks)
Post-processing and validation
The model output is a first pass, not a final answer. A few post-processing steps improve downstream data quality significantly:
from datetime import date
def validate_and_enrich(candidate: Candidate) -> Candidate:
"""
Validate extracted data and fill in derived fields.
"""
current_year = date.today().year
# Sanity-check years
for exp in candidate.work_experience:
if exp.start_year < 1950 or exp.start_year > current_year:
exp.start_year = current_year
candidate.extraction_notes.append(
f"Corrected implausible start_year for {exp.company}"
)
if exp.end_year and exp.end_year > current_year:
exp.end_year = None # Treat as current
candidate.extraction_notes.append(
f"Cleared future end_year for {exp.company}"
)
# Recalculate total experience from work history
total_months = 0
for exp in candidate.work_experience:
start_month = exp.start_month or 1
end_year = exp.end_year or current_year
end_month = exp.end_month or date.today().month
months = (end_year - exp.start_year) * 12 + (end_month - start_month)
total_months += max(0, months)
if total_months > 0:
candidate.total_years_experience = round(total_months / 12, 1)
# Deduplicate skills (LLMs sometimes list the same skill twice)
candidate.skills = list(dict.fromkeys(
s.strip().lower() for s in candidate.skills
))
return candidate
The key insight across all of these steps is that the LLM handles understanding and the code handles validation. Never trust the LLM to do arithmetic correctly (like calculating years of experience), and never trust it to produce perfectly clean strings. Structure the pipeline so each layer does what it is best at.
Confidence thresholds in practice
Low-confidence extractions should trigger manual review rather than silent pipeline progression. A confidence threshold of 0.7 works well as a starting point: above it, the record flows automatically; below it, it goes to a review queue. Over time, annotate the review queue outcomes and use them to calibrate what confidence actually means for your specific document corpus.
from enum import Enum
class ReviewOutcome(str, Enum):
ACCEPTED = "accepted" # Extraction was correct, approved as-is
CORRECTED = "corrected" # Reviewer fixed one or more fields
REJECTED = "rejected" # Resume was not parseable
def route_parse_result(result: ParseResult) -> str:
"""
Route a parse result to the appropriate downstream queue.
Returns the queue name for observability.
"""
if result.error:
return "error_queue"
if result.candidate is None:
return "error_queue"
confidence = result.candidate.extraction_confidence
if confidence >= 0.85:
# High confidence: auto-approve and index immediately
index_candidate(result.candidate)
return "auto_approved"
elif confidence >= 0.55:
# Medium confidence: send to review but pre-populate from extraction
enqueue_for_review(result.candidate, priority="normal")
return "review_queue"
else:
# Low confidence: flag for manual re-entry
enqueue_for_review(result.candidate, priority="high")
return "manual_review_queue"
Tracking routing outcomes over weeks gives you a feedback loop. If you discover that extractions with confidence 0.6–0.7 are almost always accepted without correction by reviewers, you can safely raise the auto-approve threshold. If extractions with confidence 0.9 are being corrected frequently, your confidence calibration is off and you need to revisit the extraction prompt.
Comparing parsing approaches
Before committing to an LLM-based approach, it helps to understand how it compares to the alternatives you might have considered:
Rule-based parsers (regex + section detection) are fast and cheap but fail outside their template library. They work if your candidate pool uses a narrow set of resume formats and you can afford to manually update rules as new formats appear. They are a poor fit for open job boards where format diversity is high.
Traditional ML classifiers (named entity recognition, sequence labelling) can generalise better than rules but require labelled training data in the thousands and separate models for each field type. Accuracy on rare fields like "publications" or "patents" is typically poor without large domain-specific training sets.
LLM extraction as described in this guide handles format variability naturally and requires no labelled training data for the base extraction. The trade-off is cost per parse (roughly $0.002–$0.01 per resume depending on length and model) and latency (1–3 seconds per resume with a mid-tier model). At scale, combining LLM extraction with caching for identical or near-identical documents reduces cost significantly.
A practical middle ground for high-volume pipelines: run a fast rule-based pre-classifier to separate well-structured resumes (where rules work reliably) from unusual formats (where the LLM adds the most value), and only invoke the LLM for the latter group.
Storing and querying parsed output
The schema above is designed for storage in a document database, but the shape of queries you want to run should inform your indexing strategy. Common queries in recruiting pipelines are:
- Skills overlap: "find candidates who have Python and Kubernetes"
- Experience range: "find candidates with 3–7 years of total experience"
- Recency: "find candidates with relevant work in the last 2 years"
- Location filter: "find candidates in London or willing to relocate"
# Example: MongoDB index setup for efficient candidate queries
# Run this once during database setup
from pymongo import MongoClient, ASCENDING, TEXT
def setup_candidate_indexes(db_uri: str, db_name: str) -> None:
client = MongoClient(db_uri)
db = client[db_name]
candidates = db["candidates"]
# Compound index for experience range queries
candidates.create_index([
("total_years_experience", ASCENDING),
("extraction_confidence", ASCENDING)
])
# Multi-key index on skills array enables efficient intersection queries
candidates.create_index([("skills", ASCENDING)])
# Text index on summary for full-text search
candidates.create_index([("summary", TEXT)])
# Index for filtering by location
candidates.create_index([("location", ASCENDING)])
print("Indexes created successfully.")
def search_candidates(
db,
required_skills: list[str],
min_years: float = 0,
max_years: float = 50,
min_confidence: float = 0.6
) -> list[dict]:
"""
Find candidates matching a skill and experience filter.
"""
query = {
"skills": {"$all": [s.lower() for s in required_skills]},
"total_years_experience": {"$gte": min_years, "$lte": max_years},
"extraction_confidence": {"$gte": min_confidence}
}
return list(db["candidates"].find(query).sort("total_years_experience", -1))
Storing skills as a lowercase list (after deduplication in validate_and_enrich) makes $all queries fast and avoids case-sensitivity mismatches. The same normalisation should apply at query time: always lowercase the skill list before querying, and consider expanding common abbreviations ("js" → "javascript") as a preprocessing step on both stored skills and query terms.
The final advice for any production resume parser is to treat the LLM extraction as a first draft, not a ground truth. Build review tooling from day one, track correction rates per field, and use that signal to continuously refine your extraction prompt and confidence calibration. A parser that gets better every week because you are measuring it is far more valuable than one that is slightly more accurate on day one but impossible to improve systematically.
Handling multi-page and multi-column layouts
Long resumes — academic CVs, senior professionals with extensive publication lists — often span four to eight pages and use two-column layouts. PDF extraction libraries frequently linearise columns incorrectly, interleaving left-column and right-column text in the wrong order. This produces incoherent text that confuses the LLM even though the original document was perfectly formatted.
The most reliable fix is to detect multi-column layouts and switch to a layout-aware extraction strategy:
import pdfplumber
from pathlib import Path
def extract_text_layout_aware(pdf_path: str) -> str:
"""
Extract text from a PDF using layout analysis to handle multi-column resumes.
Falls back to simple extraction if layout analysis fails.
"""
all_text = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
# Detect if the page has a two-column layout by checking
# the x-distribution of words
words = page.extract_words()
if not words:
continue
mid_x = page.width / 2
left_words = [w for w in words if float(w["x0"]) < mid_x - 20]
right_words = [w for w in words if float(w["x0"]) >= mid_x + 20]
# If more than 30% of words are on each side, treat as two-column
total = len(words)
if total > 10 and len(left_words) / total > 0.3 and len(right_words) / total > 0.3:
# Extract each column separately, then concatenate
left_bbox = (0, 0, mid_x, page.height)
right_bbox = (mid_x, 0, page.width, page.height)
left_text = page.within_bbox(left_bbox).extract_text() or ""
right_text = page.within_bbox(right_bbox).extract_text() or ""
all_text.append(left_text)
all_text.append(right_text)
else:
# Single-column: use standard extraction
text = page.extract_text() or ""
all_text.append(text)
return "\n\n".join(t for t in all_text if t.strip())
For academic CVs, publication sections present a special challenge: they can run to dozens of entries in various citation formats (APA, IEEE, Chicago). Rather than trying to parse every publication into a structured object, extract them as a raw list and store them in a separate publications: list[str] field. If you need structured publication data downstream, run a second targeted extraction pass on just that section.
Improving accuracy with few-shot examples
The extraction prompt shown earlier is zero-shot: it gives the model instructions but no examples of input/output pairs. For domains with unusual formatting conventions — academic CVs, freelance portfolios, executive bios — adding two or three few-shot examples in the system prompt significantly improves consistency.
FEW_SHOT_EXAMPLES = """
EXAMPLE 1
Resume excerpt: "Jane Smith | [email protected] | github.com/jsmith
Staff Engineer, Stripe (Feb 2020 – present)
Led platform reliability initiatives across payments infrastructure."
Expected extraction highlights:
- full_name: "Jane Smith"
- email: "[email protected]"
- github_url: "https://github.com/jsmith"
- work_experience[0].company: "Stripe"
- work_experience[0].title: "Staff Engineer"
- work_experience[0].start_year: 2020, start_month: 2
- work_experience[0].end_year: null (current)
EXAMPLE 2
Resume excerpt: "EDUCATION
MIT, Cambridge MA
S.B. Computer Science, 2016
GPA: 3.9/4.0"
Expected extraction highlights:
- education[0].institution: "MIT"
- education[0].degree: "Bachelor of Science"
- education[0].field_of_study: "Computer Science"
- education[0].graduation_year: 2016
- education[0].gpa: 3.9
"""
def parse_resume_with_examples(resume_text: str) -> Candidate:
"""
Parse a resume using few-shot examples in the system prompt.
More accurate for unusual formats, at the cost of a slightly larger prompt.
"""
system = f"""You are a resume parser. Extract structured information from
the resume text provided.
{FEW_SHOT_EXAMPLES}
Rules:
- Normalise dates: year as integer, month as 1-12
- For current positions, leave end_year null
- Calculate total_years_experience from work history, not self-reported values
- Set extraction_confidence based on resume clarity (1.0 = clear, 0.3 = ambiguous)
- Use extraction_notes to flag ambiguities"""
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=2048,
system=system,
messages=[{"role": "user", "content": f"Parse this resume:\n\n{resume_text}"}],
tools=[{
"name": "extract_candidate",
"description": "Extract structured candidate information",
"input_schema": CANDIDATE_SCHEMA
}],
tool_choice={"type": "tool", "name": "extract_candidate"}
)
tool_use = next(b for b in response.content if b.type == "tool_use")
return Candidate(**tool_use.input)
Few-shot examples add tokens to every prompt, so there is a direct cost trade-off. Measure accuracy improvement on your evaluation set and calculate whether the accuracy gain justifies the token increase. In practice, two well-chosen examples add 150–300 tokens to a prompt that is already 3,000–6,000 tokens for a typical resume, so the percentage cost increase is small while the accuracy gain on edge cases can be substantial.