A mortgage loan file for a self-employed borrower contains, on average, 150 to 200 pages. Tax returns for two years. Personal and business bank statements for three months. A profit and loss statement. An appraisal report. Title documents. A credit report. Insurance declarations. The loan officer's job, before underwriting can even start, is to collect all of that, verify that each document is present, extract the relevant data, and confirm that the numbers are internally consistent.
At most financial institutions in 2026, a meaningful portion of that process still involves a human opening each PDF and manually entering data. That is not because lenders do not have technology. It is because the document types are too varied, the layouts too inconsistent, and the edge cases too numerous for traditional extraction pipelines to handle reliably. The result is a loan processing workflow that bottlenecks at the document layer regardless of how automated everything downstream has become.
This article is about fixing that bottleneck. We will cover the loan document landscape in detail, build out a working extraction pipeline using LlamaParse for the most common loan document types, show how cross-document validation works in practice, and address the compliance requirements that govern how loan data gets handled. Code throughout.
The Loan Document Landscape
Before building anything, it helps to understand exactly what you are dealing with. Loan files are not a single document type with a consistent format. They are a collection of document types, each with their own structure, their own format variability, and their own extraction challenges.
| Document Type | Key Fields to Extract | Common Extraction Challenges | LlamaParse Mode |
|---|---|---|---|
| W-2 / 1099 | Employer, gross income, federal tax withheld, YTD totals | Box numbering varies by issuer; multiple W-2s per applicant | Cost-Effective |
| Bank statements | Account number, monthly deposits, average balance, recurring credits | Table structure varies by bank; 3-12 months required | Cost-Effective |
| Tax returns (1040) | AGI, Schedule C net profit, Schedule E income, line 1a vs 1b | Multi-schedule structure; income distributed across pages | Agentic |
| Pay stubs | Gross pay, net pay, YTD, pay frequency, employer name | Dozens of payroll software layouts; YTD math check needed | Cost-Effective |
| Business financials | Revenue, EBITDA, total assets, debt obligations | Non-standard formatting; narrative mixed with tables | Agentic |
| Appraisal reports | Property value, comp analysis, condition rating | PDF generated from proprietary appraisal software | Agentic |
| Title documents | Legal description , liens, easements, ownership chain | Dense legal text; entity names must match application | Agentic |
The LlamaParse mode recommendation in the table above reflects a deliberate cost-accuracy trade-off. Balanced mode handles well-structured documents efficiently. Premium mode is appropriate when scan quality may be variable. Agentic mode is for complex, multi-section documents where the model needs to reason about document structure rather than just extract labeled fields. Using agentic mode for everything is expensive and unnecessary. Using balanced mode for everything misses accuracy on the hard cases.
A commercial loan file raises the complexity further. A single commercial real estate loan can include 50 to 200 pages covering entity documents, operating agreements, rent rolls, environmental reports, UCC filings, and years of business financial statements. The extraction challenge is not just reading each document. It is maintaining the relationships between documents: the entity on the title report should match the borrower entity on the application. The income on the tax return should be consistent with the deposits on the bank statements. The appraised value should support the requested loan-to-value ratio.
Building the Extraction Pipeline
Here is a working implementation. We will build this in stages: individual document extraction, multi-document orchestration, and cross-document validation. Each stage corresponds to a real step in the loan processing workflow.
Setup
html
!pip install "llama-cloud>=2.1" pydantic
import os
import time
import asyncio
from dataclasses import dataclass
from typing import Optional, Literal
from pydantic import BaseModel, Field
from llama_cloud import LlamaCloud
client = LlamaCloud(api_key=os.environ["LLAMA_CLOUD_API_KEY"]) On top, we need one central function that helps us to open documents and extract information from it:
html
def extract_document(
file_path: str,
schema: dict,
tier: str,
system_prompt: str,
) -> tuple[dict, dict]:
file_obj = client.files.create(
file=file_path,
purpose="extract",
)
job = client.extract.create(
file_input=file_obj.id,
configuration={
"data_schema": schema,
"extraction_target": "per_doc",
"tier": tier,
"confidence_scores": True,
"system_prompt": system_prompt,
},
)
while job.status not in ("COMPLETED", "FAILED", "CANCELLED"):
time.sleep(2)
job = client.extract.get(job.id)
if job.status != "COMPLETED":
raise RuntimeError(f"Extraction failed with status: {job.status}")
job = client.extract.get(
job.id,
expand=["extract_metadata"],
)
return job.extract_result, (job.extract_metadata or {}) Example 1: Extracting Income from a W-2
W-2 forms are theoretically standardized by the IRS. In practice, every payroll software generates a slightly different layout, font, and field arrangement. Some W-2s arrive as native PDFs. Many arrive as scans with varying quality. This extraction needs to be robust to all of it.
html
class W2Form(BaseModel):
employer_name: str = Field(description="Employer name from Box c")
employer_ein: str = Field(description="Employer EIN from Box b, format XX-XXXXXXX")
employee_name: str = Field(description="Employee name from Box e")
employee_ssn_last4: str = Field(description="Last 4 digits only from Box a")
tax_year: int
wages_tips: float = Field(description="Box 1")
federal_tax_withheld: float = Field(description="Box 2")
social_security_wages: float = Field(description="Box 3")
medicare_wages: float = Field(description="Box 5")
state: Optional[str] = Field(default=None, description="Box 15")
state_wages: Optional[float] = Field(default=None, description="Box 16")
class W2Extraction(BaseModel):
w2_forms: list[W2Form] = Field(
description="All W-2 forms found in the document. If only one W-2 exists, return one item."
)
w2, metadata = extract_document(
file_path="w2_2024.pdf",
schema=W2Extraction.model_json_schema(),
tier="cost_effective",
system_prompt=(
"This is an IRS Form W-2. Extract all W-2 forms found in the document. "
"If multiple W-2s appear on one page, return all of them."
),
)
annual_income = sum(form["wages_tips"] for form in w2["w2_forms"])
print(f"Annual W-2 income: ${annual_income:,.2f}") Example 2: Bank Statement Income Analysis
Bank statements are the hardest income document to extract from because the relevant information (regular income deposits) is embedded in a transaction table alongside hundreds of irrelevant transactions. The extraction needs to identify deposit transactions, classify them by type (payroll, transfer, ACH, etc.), and calculate a monthly average.
html
class BankTransaction(BaseModel):
date: str = Field(description="Transaction date as ISO date")
description: str
amount: float = Field(description="Positive for deposits, negative for withdrawals")
transaction_type: Literal[
"payroll",
"transfer",
"ach_credit",
"ach_debit",
"check",
"wire",
"atm",
"other",
]
class BankStatementExtraction(BaseModel):
bank_name: str
account_number_last4: str
statement_period_start: str = Field(description="ISO date")
statement_period_end: str = Field(description="ISO date")
beginning_balance: float
ending_balance: float
total_deposits: float
total_withdrawals: float
transactions: list[BankTransaction]
stmt, metadata = extract_document(
file_path="bank_statement_march.pdf",
schema=BankStatementExtraction.model_json_schema(),
tier="agentic",
system_prompt=(
"This is a bank statement. Extract the statement summary fields and all transactions. "
"Classify each transaction by type. Use positive amounts for deposits and negative amounts for withdrawals."
),
)
payroll_deposits = [
t for t in stmt["transactions"]
if t["transaction_type"] == "payroll" and t["amount"] > 0
]
total_payroll = sum(t["amount"] for t in payroll_deposits)
print(f"Payroll deposits this period: ${total_payroll:,.2f}")
print(f"Number of payroll deposits: {len(payroll_deposits)}") Example 3: Self-Employed Income from Schedule C
Self-employed borrowers are where loan processing gets genuinely hard. A Schedule C shows gross receipts and business expenses. The underwriter needs net profit, which requires subtracting allowable expenses from gross income. But some expenses (depreciation, depletion) are added back for qualifying income under standard underwriting guidelines. This is not a simple extraction. It requires understanding the structure of the form.
html
class ScheduleCExtraction(BaseModel):
business_name: Optional[str] = Field(default=None, description="Line A")
principal_business_code: Optional[str] = Field(default=None, description="Line B")
tax_year: int
gross_receipts: float = Field(description="Line 1")
returns_allowances: Optional[float] = Field(default=0, description="Line 2")
cost_of_goods_sold: Optional[float] = Field(default=0, description="Line 4")
gross_profit: float = Field(description="Line 5")
total_expenses: float = Field(description="Line 28")
tentative_profit: Optional[float] = Field(default=None, description="Line 29")
net_profit_loss: float = Field(description="Line 31")
depreciation: Optional[float] = Field(default=0, description="Line 13")
depletion: Optional[float] = Field(default=0, description="Line 12 if present")
home_office_deduction: Optional[float] = Field(default=0, description="Line 30 if present")
sched_c, metadata = extract_document(
file_path="schedule_c_2024.pdf",
schema=ScheduleCExtraction.model_json_schema(),
tier="agentic_plus",
system_prompt=(
"This is IRS Schedule C, Profit or Loss From Business. "
"Extract the requested fields exactly as labeled on the form."
),
)
net_profit = sched_c["net_profit_loss"]
depreciation_addback = sched_c.get("depreciation") or 0
depletion_addback = sched_c.get("depletion") or 0
qualifying_income = net_profit + depreciation_addback + depletion_addback
monthly_qualifying = qualifying_income / 12
print(f"Schedule C net profit: ${net_profit:,.2f}")
print(f"Add-backs: ${depreciation_addback + depletion_addback:,.2f}")
print(f"Qualifying annual income: ${qualifying_income:,.2f}")
print(f"Monthly qualifying income: ${monthly_qualifying:,.2f}") Multi-Document Orchestration
In practice, a loan file contains multiple documents of multiple types. The orchestration layer processes them in parallel, handles failures gracefully, and assembles results into a unified loan file record.
html
@dataclass
class LoanDocumentResult:
doc_type: str
file_path: str
extracted_data: dict
confidence_scores: dict
processing_errors: list[str]
SCHEMA_BY_DOC_TYPE = {
"w2": W2Extraction.model_json_schema(),
"bank_statement": BankStatementExtraction.model_json_schema(),
"schedule_c": ScheduleCExtraction.model_json_schema(),
}
TIER_BY_DOC_TYPE = {
"w2": "cost_effective",
"bank_statement": "agentic",
"schedule_c": "agentic_plus",
"pay_stub": "agentic",
"tax_return_1040": "agentic_plus",
}
PROMPT_BY_DOC_TYPE = {
"w2": (
"This is an IRS Form W-2. Extract all W-2 forms found in the document."
),
"bank_statement": (
"This is a bank statement. Extract summary fields and classify transactions."
),
"schedule_c": (
"This is IRS Schedule C. Extract the requested fields exactly as labeled."
),
"pay_stub": (
"This is a pay stub. Extract gross pay, net pay, YTD values, pay frequency, and employer name."
),
"tax_return_1040": (
"This is an IRS Form 1040 tax return. Extract income, AGI, deductions, and tax year fields."
),
}
async def extract_loan_document(file_path: str, doc_type: str) -> LoanDocumentResult:
schema = SCHEMA_BY_DOC_TYPE.get(doc_type)
if not schema:
return LoanDocumentResult(
doc_type=doc_type,
file_path=file_path,
extracted_data={},
confidence_scores={},
processing_errors=[f"No schema configured for type: {doc_type}"],
)
try:
data, metadata = await asyncio.to_thread(
extract_document,
file_path,
schema,
TIER_BY_DOC_TYPE.get(doc_type, "agentic"),
PROMPT_BY_DOC_TYPE.get(doc_type, ""),
)
return LoanDocumentResult(
doc_type=doc_type,
file_path=file_path,
extracted_data=data,
confidence_scores=metadata.get("confidence_scores", {}),
processing_errors=[],
)
except Exception as e:
return LoanDocumentResult(
doc_type=doc_type,
file_path=file_path,
extracted_data={},
confidence_scores={},
processing_errors=[str(e)],
)
async def process_loan_file(documents: list[tuple[str, str]]) -> list[LoanDocumentResult]:
tasks = [
extract_loan_document(file_path, doc_type)
for file_path, doc_type in documents
]
return await asyncio.gather(*tasks)
loan_documents = [
("docs/w2_2024.pdf", "w2"),
("docs/w2_2023.pdf", "w2"),
("docs/bank_jan.pdf", "bank_statement"),
("docs/bank_feb.pdf", "bank_statement"),
("docs/bank_mar.pdf", "bank_statement"),
("docs/schedule_c_2024.pdf", "schedule_c"),
]
results = asyncio.run(process_loan_file(loan_documents))
successful = [r for r in results if not r.processing_errors]
failed = [r for r in results if r.processing_errors]
print(f"Processed: 9{len(successful)}/{len(results)} documents successfully") Cross-Document Validation: Where Manual Errors Get Caught
Parallel extraction is only useful if the results are checked against each other. A borrower who submits a W-2 showing $120,000 in wages but whose bank statements show only $6,000 in monthly deposits has an inconsistency that needs explaining. An automated validation layer catches this before an underwriter touches the file.
html
class LoanFileValidator:
INCOME_TOLERANCE = 0.15
MIN_BANK_STATEMENTS = 3
def __init__(self, results: list[LoanDocumentResult]):
self.results = results
self.flags = []
def validate_income_consistency(self):
w2_results = [
r for r in self.results
if r.doc_type == "w2" and not r.processing_errors
]
bank_results = [
r for r in self.results
if r.doc_type == "bank_statement" and not r.processing_errors
]
if not w2_results or not bank_results:
return
w2_annual = 0
for result in w2_results:
forms = result.extracted_data.get("w2_forms", [])
w2_annual += sum(form.get("wages_tips", 0) for form in forms)
total_deposits = sum(
r.extracted_data.get("total_deposits", 0)
for r in bank_results
)
months_covered = len(bank_results)
if months_covered == 0:
return
annualized_deposits = (total_deposits / months_covered) * 12
if w2_annual > 0:
variance = abs(w2_annual - annualized_deposits) / w2_annual
if variance > self.INCOME_TOLERANCE:
self.flags.append({
"type": "INCOME_INCONSISTENCY",
"severity": "HIGH",
"detail": (
f"W-2 income ${w2_annual:,.0f} vs "
f"annualized deposits ${annualized_deposits:,.0f} "
f"({variance:.1%} variance)"
),
"action": "MANUAL_REVIEW_REQUIRED",
})
def validate_document_completeness(self, required_types: list[str]):
present = {
r.doc_type
for r in self.results
if not r.processing_errors
}
missing = set(required_types) - present
for doc_type in missing:
self.flags.append({
"type": "MISSING_DOCUMENT",
"severity": "CRITICAL",
"detail": f"Required document not received: {doc_type}",
"action": "REQUEST_FROM_BORROWER",
})
def run_all_validations(self):
self.validate_income_consistency()
self.validate_document_completeness([
"w2",
"bank_statement",
"schedule_c",
])
return self.flags
validator = LoanFileValidator(results)
flags = validator.run_all_validations()
if not flags:
print("File passes automated validation. Ready for underwriting.")
else:
print(f"{len(flags)} issue(s) found:")
for flag in flags:
print(f' [{flag["severity"]}] {flag["type"]}: {flag["detail"]}') The validation logic above is a starting point. Production implementations add more checks: verifying that the name on tax returns matches the name on the application, confirming that employer EINs on W-2s are valid format, checking that bank statement account numbers are consistent across months, and flagging unusually large one-time deposits that could indicate borrowed funds rather than income.
Automating Compliance Checks in the Loan Workflow
Regulatory requirements in loan processing are specific and consequential. RESPA, TILA, ECOA, and the Fair Housing Act each impose obligations on how loan applications are handled, what disclosures are required, and how decisions are documented. An automated workflow that speeds up processing but creates compliance gaps is worse than a slow manual workflow that gets compliance right.
The document extraction layer supports compliance in two ways. First, it produces a complete, timestamped audit trail of every extraction: what data was pulled from which document, when, with what confidence score. This is the evidence that regulators ask for when reviewing underwriting decisions. Second, it enables automated compliance checks that would be impractical to run manually at scale.
TRID Compliance Validation
The TILA-RESPA Integrated Disclosure rule requires that loan estimates and closing disclosures match within defined tolerance thresholds. Fees in certain categories cannot increase by more than 0% or 10% between the Loan Estimate and the Closing Disclosure depending on the category. Manual checking of TRID tolerance is time-consuming and error-prone.
html
TRID_TOLERANCES = {
"zero_tolerance": [
"origination_charges",
"transfer_taxes",
"lender_required_services_borrower_specified",
],
"ten_percent_tolerance": [
"recording_fees",
"third_party_services_borrower_not_permitted",
],
"unlimited_tolerance": [
"prepaid_interest",
"property_insurance",
"escrow_reserves",
],
}
def check_trid_tolerance(loan_estimate: dict, closing_disclosure: dict) -> list:
violations = []
for fee_category in TRID_TOLERANCES["zero_tolerance"]:
le_amount = loan_estimate.get(fee_category, 0)
cd_amount = closing_disclosure.get(fee_category, 0)
if cd_amount > le_amount:
violations.append({
"rule": "TRID_ZERO_TOLERANCE",
"category": fee_category,
"le_amount": le_amount,
"cd_amount": cd_amount,
"increase": cd_amount - le_amount,
"severity": "REGULATORY_VIOLATION",
})
for fee_category in TRID_TOLERANCES["ten_percent_tolerance"]:
le_amount = loan_estimate.get(fee_category, 0)
cd_amount = closing_disclosure.get(fee_category, 0)
if le_amount > 0:
increase_pct = (cd_amount - le_amount) / le_amount
if increase_pct > 0.10:
violations.append({
"rule": "TRID_TEN_PERCENT_TOLERANCE",
"category": fee_category,
"le_amount": le_amount,
"cd_amount": cd_amount,
"increase_pct": f"{increase_pct:.1%}",
"severity": "REGULATORY_VIOLATION",
})
return violations The Real-Time Approval Workflow: Putting It Together
The individual extraction and validation components above combine into a loan processing workflow that moves from document receipt to underwriting-ready file without manual intervention on standard cases.
The workflow has four stages that happen automatically. Document intake and classification: incoming files are classified by document type and routed to the appropriate extraction parser. Parallel extraction: all documents in the loan file are processed simultaneously, reducing total extraction time to the duration of the slowest single document rather than the sum of all documents. Validation and flagging: extracted data is checked for internal consistency, cross-document consistency, and compliance requirements. Routing decision: files that pass all validations move automatically to the underwriting queue. Files with flags route to a review queue with the specific issues surfaced for analyst attention.
The operational impact is measurable. A loan file that previously took two to three days of document review before reaching an underwriter can move through this pipeline in minutes for standard cases. The human review step does not disappear. It shifts from reviewing every document on every file to reviewing only the flagged cases, which represent a fraction of the total volume.
Confidence Scores and the Human-in-the-Loop Threshold
Field-level confidence scores from LlamaParse are the mechanism that makes the routing decision defensible. A gross income figure extracted with 97% confidence from a clean W-2 does not require manual verification. The same field extracted with 71% confidence from a low-quality scan of a handwritten pay stub does.
Setting the confidence threshold is a calibration decision that depends on your risk tolerance and the downstream use of the extracted data. For fields that feed directly into a credit decision, the threshold should be high. For fields used only for file completeness checks, a lower threshold is acceptable.
html
TRID_TOLERANCES = {
"zero_tolerance": [
"origination_charges",
"transfer_taxes",
"lender_required_services_borrower_specified",
],
"ten_percent_tolerance": [
"recording_fees",
"third_party_services_borrower_not_permitted",
],
"unlimited_tolerance": [
"prepaid_interest",
"property_insurance",
"escrow_reserves",
],
}
def check_trid_tolerance(loan_estimate: dict, closing_disclosure: dict) -> list:
violations = []
for fee_category in TRID_TOLERANCES["zero_tolerance"]:
le_amount = loan_estimate.get(fee_category, 0)
cd_amount = closing_disclosure.get(fee_category, 0)
if cd_amount > le_amount:
violations.append({
"rule": "TRID_ZERO_TOLERANCE",
"category": fee_category,
"le_amount": le_amount,
"cd_amount": cd_amount,
"increase": cd_amount - le_amount,
"severity": "REGULATORY_VIOLATION",
})
for fee_category in TRID_TOLERANCES["ten_percent_tolerance"]:
le_amount = loan_estimate.get(fee_category, 0)
cd_amount = closing_disclosure.get(fee_category, 0)
if le_amount > 0:
increase_pct = (cd_amount - le_amount) / le_amount
if increase_pct > 0.10:
violations.append({
"rule": "TRID_TEN_PERCENT_TOLERANCE",
"category": fee_category,
"le_amount": le_amount,
"cd_amount": cd_amount,
"increase_pct": f"{increase_pct:.1%}",
"severity": "REGULATORY_VIOLATION",
})
return violations Operational Efficiency: What Actually Changes
The efficiency gains from an automated loan processing workflow are real, but they compound in ways that are not obvious from looking at individual document extraction times.
The first-order gain is speed. A document extraction pipeline processing a full loan file in parallel takes two to five minutes for a standard residential file. Manual collection and data entry takes two to three days. That is a 99% reduction in document processing time, which translates directly into faster loan decisions, lower abandonment rates during the application process, and the ability to handle volume spikes without adding headcount.
The second-order gain is error reduction. Manual data entry in loan processing has an error rate that compounds across the file. A single transposed digit in an income figure can propagate through debt-to-income calculations, affect the loan amount, and reach the credit bureau submission before anyone catches it. Automated extraction with cross-document validation catches these errors systematically before they propagate. The cost of catching an error at the extraction stage is a routing to manual review. The cost of catching it at the credit bureau stage is a correction request, delay, and potential compliance issue.
The third-order gain is analyst capacity. Loan officers and processors who are not manually entering data from documents are available for higher-value work: complex cases that genuinely require judgment, customer communication, and relationship management. The competitive advantage compounds over time as the team's capacity for complex work grows without proportional headcount growth.
Where to Start
The practical starting point is the document type that creates the most processing bottleneck in your current workflow. For most residential lenders, that is bank statements. They are high-volume, format-variable, and the relevant information (income deposits) requires semantic understanding to extract correctly from a transaction table.
Build the extraction schema for bank statements, run it against three months of real statements from your actual applicant pool, and measure field-level accuracy against manually verified ground truth. The accuracy threshold that matters is on the fields that feed your underwriting model, not the overall extraction accuracy across all fields.
LlamaParse includes 10,000 free credits on signup. The code examples in this article are working starting points. The path from these examples to a production pipeline runs through schema validation, confidence threshold calibration, cross-document validation logic, and integration with your loan origination system. None of those steps requires reinventing what is above. They require adapting it to your specific document corpus and your specific underwriting rules.
The loan processing workflow is one of the most document-intensive in financial services. It is also one of the most automation-ready, because the documents are well-defined, the data requirements are specific, and the cost of manual processing is measurable and high. That combination is exactly where agentic document parsing creates the most durable operational advantage.