Automate 3-Way Match Accounts Payable with Python
An invoice arrives for $12,400. Somewhere in your system there's a purchase order for $11,800 and a receiving record confirming only part of the order actually showed up. If nobody catches the mismatch before payment, you've just overpaid a vendor by $600—multiplied across every invoice your AP team processes manually every month. Automate 3-way match accounts payable with Python, and mismatches like this get flagged automatically before a check ever goes out, not discovered in a quarterly audit six months later.
The 3-way match—comparing the purchase order, the goods receipt, and the vendor invoice before approving payment—is a foundational accounts payable control. It's also one of the most manually tedious parts of finance operations when done by hand, which is exactly why so many companies either skip it under time pressure or process it too slowly to catch problems before payment goes out.
Why Manual 3-Way Matching Fails Under Volume
At low invoice volume, a person can manually pull up a PO, check the receiving log, and compare it against the invoice line by line. At real volume—hundreds of invoices a month—this becomes unsustainable. AP teams either fall behind (delaying vendor payments and damaging relationships) or start skipping the match for smaller invoices, exactly the kind of shortcut that lets billing errors slip through consistently.
The other failure mode is inconsistency. Different AP clerks apply different tolerance thresholds for "close enough" quantity or price variances, meaning the same type of discrepancy might get flagged by one reviewer and waved through by another. That inconsistency makes it hard to trust your AP controls even when the process is technically being followed.
Python automates the comparison logic itself—consistent tolerance rules, applied instantly, to every invoice regardless of volume—while keeping a human in the loop only for genuine exceptions that need judgment.
Building the 3-Way Match Engine
Step 1: Pulling the Three Records
Most ERP systems (NetSuite, SAP, Dynamics, QuickBooks) expose purchase orders, receipts, and invoices via API or exportable reports. Pull all three into a consistent structure:
1import pandas as pd23purchase_orders = pd.read_csv('open_purchase_orders.csv')4receipts = pd.read_csv('goods_receipts.csv')5invoices = pd.read_csv('pending_invoices.csv')67# Standardize the join key across all three sources8for df in [purchase_orders, receipts, invoices]:9 df['po_number'] = df['po_number'].astype(str).str.strip()
Step 2: Defining Match Tolerance Rules
Perfect matches are rare even on legitimate invoices—shipping costs, small price adjustments, and partial deliveries create expected variance. Encode acceptable tolerance explicitly instead of requiring an exact match:
1PRICE_TOLERANCE_PCT = 0.02 # 2% price variance allowed2QUANTITY_TOLERANCE_PCT = 0.05 # 5% quantity variance allowed34def within_tolerance(expected, actual, tolerance_pct):5 if expected == 0:6 return actual == 07 variance = abs(actual - expected) / expected8 return variance <= tolerance_pct
Step 3: Running the Match Logic
Join the three datasets on PO number and line item, then apply the tolerance checks to flag matches, near-matches, and true discrepancies:
1merged = invoices.merge(purchase_orders, on='po_number', suffixes=('_inv', '_po'))2merged = merged.merge(receipts, on='po_number', suffixes=('', '_receipt'))34def evaluate_match(row):5 price_ok = within_tolerance(row['unit_price_po'], row['unit_price_inv'], PRICE_TOLERANCE_PCT)6 qty_ok = within_tolerance(row['quantity_received'], row['quantity_invoiced'], QUANTITY_TOLERANCE_PCT)78 if price_ok and qty_ok:9 return 'Matched'10 elif not price_ok and qty_ok:11 return 'Price Discrepancy'12 elif price_ok and not qty_ok:13 return 'Quantity Discrepancy'14 else:15 return 'Multiple Discrepancies'1617merged['match_status'] = merged.apply(evaluate_match, axis=1)
Step 4: Routing Results for Action
Matched invoices can flow straight to auto-approval for payment. Discrepancies need a human to investigate—but instead of starting from scratch, they get a pre-built summary of exactly what's wrong:
1matched = merged[merged['match_status'] == 'Matched']2exceptions = merged[merged['match_status'] != 'Matched']34matched.to_csv('auto_approved_invoices.csv', index=False)5exceptions.to_csv('ap_review_queue.csv', index=False)67print(f"{len(matched)} invoices auto-approved, {len(exceptions)} flagged for review")
Building the Exception Review Workflow
Generating Actionable Exception Reports
A raw CSV of mismatches isn't enough—AP staff need context to resolve issues fast. Generate a summary showing the exact variance and likely cause for each flagged invoice:
1exceptions['price_variance'] = exceptions['unit_price_inv'] - exceptions['unit_price_po']2exceptions['qty_variance'] = exceptions['quantity_invoiced'] - exceptions['quantity_received']3exceptions['likely_cause'] = exceptions.apply(4 lambda r: 'Possible price increase not reflected in PO' if abs(r['price_variance']) > 05 else 'Partial shipment - invoice may be premature',6 axis=17)
Notifying the Right Reviewer Automatically
Route exceptions to the appropriate reviewer based on discrepancy type and dollar impact, rather than dumping everything into one shared queue:
1def assign_reviewer(row):2 if row['match_status'] == 'Price Discrepancy' and row['price_variance'] * row['quantity_invoiced'] > 1000:3 return 'ap_manager@company.com'4 return 'ap_clerk@company.com'56exceptions['assigned_to'] = exceptions.apply(assign_reviewer, axis=1)
Send a daily digest email per reviewer (see our guide on automating weekly email reports with Python) summarizing their assigned exceptions, so nothing sits unreviewed in a shared spreadsheet nobody owns.
Handling Freight, Tax, and Fee Line Items
Real invoices rarely match a PO line-for-line—freight charges, taxes, and processing fees often appear on the invoice with no corresponding PO line item. Rather than flagging every invoice with a freight charge as a discrepancy, separate these expected non-PO charges into their own category during matching, comparing them against a reasonableness threshold (like "freight should not exceed 5% of subtotal") instead of trying to force an exact PO match that was never going to exist for these line types.
1NON_PO_CHARGE_TYPES = ['freight', 'tax', 'processing_fee']23def is_expected_non_po_charge(line_description, subtotal, charge_amount):4 if any(term in line_description.lower() for term in NON_PO_CHARGE_TYPES):5 return charge_amount <= subtotal * 0.056 return False
Best Practices / Pro Tips
Set tolerance thresholds based on actual historical data, not guesswork. Pull six months of past invoices and measure typical legitimate variance before deciding on a 2% or 5% threshold—too tight, and you'll flag routine shipping cost adjustments as discrepancies constantly; too loose, and real billing errors slip through.
Always keep auto-approval reversible. Even matched invoices should be logged with full detail so a later audit can trace exactly why a payment was approved automatically, not just that it was.
Review your discrepancy categories quarterly. If "Quantity Discrepancy" keeps flagging the same vendor every month, that's not a data problem—it's a vendor relationship problem worth escalating separately from the automated workflow.
Conclusion
Automating 3-way match accounts payable with Python turns a control that often gets skipped under time pressure into one that runs consistently on every invoice, regardless of volume. Matched invoices flow straight through to payment, and genuine discrepancies land in front of the right reviewer with the context needed to resolve them quickly—instead of a raw mismatch nobody has time to investigate properly.
Start by pulling your PO, receipt, and invoice data into a consistent format, define tolerance thresholds based on real historical variance, and build outward from there into automated reviewer routing and daily exception digests.
Frequently Asked Questions
What's a reasonable tolerance threshold for price and quantity variance?
There's no universal number—it depends on your industry and vendor relationships. Start by analyzing six months of historical invoices to see what variance was ultimately approved as legitimate, and set your threshold slightly above that baseline rather than guessing.
Can this handle partial shipments and partial invoicing?
Yes, if you structure your receipt and invoice data at the line-item level rather than the PO level as a whole. Partial shipments should match against the portion actually received, not the full original PO quantity, which the tolerance logic above already accounts for.
Does 3-way match automation replace the need for an AP team?
No. It removes the repetitive comparison work so your AP team spends their time on genuine exceptions and vendor relationship issues, rather than manually checking every invoice—including ones that were always going to match cleanly.
How does this integrate with our existing ERP's payment approval workflow?
Most ERPs expose an API or scheduled import method for updating invoice approval status. Once your Python script determines "Matched" status, you can push that status back into the ERP automatically so the existing payment run picks it up without manual re-entry.
Can this script catch duplicate invoice submissions, not just PO mismatches?
Yes—add a separate check before the 3-way match logic that flags any invoice with a vendor, amount, and invoice number combination matching a previously processed invoice within a defined lookback window. Duplicate invoice detection is a related but distinct control worth running alongside 3-way matching, since duplicates can slip through even when the PO and receipt data technically match.
Related articles: Automate Expense Report Processing with Python, Automate Invoice Approval: A Finance Workflow Guide, Automate Invoice Processing with Python and OCR
Sponsored Content
Interested in advertising? Reach automation professionals through our platform.