agent skill · reconciliation · pandas
Merge Excel Lists with SQL Extracts Without Losing Rows
Join a spreadsheet list against a database extract safely — parameterized queries, indicator and validate flags, and pre/post row counts so nothing drops silently.
When to use this skill
- You have a list in Excel (account numbers, vendor IDs, employee IDs) and need to enrich it with fields pulled from a SQL database.
- You’ve been burned by a VLOOKUP or merge that silently dropped rows, silently duplicated rows, or matched nothing because one side stored the key as text and the other as a number.
- Someone on the team is building SQL strings by pasting IDs into the query text. Stop them — that’s an injection risk and a formatting time bomb.
The pattern: pull from the database with a parameterized query, then merge with how='left', indicator=True, and validate= so every row is accounted for and any fan-out is caught immediately.
Inputs it expects
- Excel list: one row per item you care about, with a clean key column (e.g.,
account_id). - Database connection: pyodbc/SQLAlchemy connection string to the source system.
- Expected relationship: know before you merge whether the database should return one row per key (
one_to_one) or many (one_to_many). This becomes thevalidate=argument. - Keys on both sides in the same dtype — cast both to string (and strip whitespace/leading zeros consistently) before merging.
Steps
- Load the Excel list and record its row count. This is your control total — the output must have at least this many rows, and exactly this many if the relationship is one-to-one.
- Build a parameterized
IN (...)query with?placeholders — never f-string the values into the SQL text. For 1,000+ keys, query in batches. - Pull the extract and record its row count too.
- Normalize the key columns on both sides (dtype, strip, case).
- Merge with
how='left'(keep every Excel row),indicator=True(label each row’s match status), andvalidate=(blow up loudly if the shape isn’t what you expected). - Tabulate
_merge: matched + unmatched must equal the Excel total. Export theleft_onlyrows as the exception list.
Code
import pandas as pd
import pyodbc
# 1. Load the Excel list — this row count is the control total
excel_df = pd.read_excel('accounts.xlsx') # account_id, account_desc, gl_balance
rows_in = len(excel_df)
excel_df['account_id'] = excel_df['account_id'].astype(str).str.strip()
account_list = excel_df['account_id'].unique().tolist()
# 2. Parameterized query — placeholders, never string-pasted values
conn = pyodbc.connect('Driver={SQL Server};Server=MyServer;Database=MyDB;Trusted_Connection=yes;')
def query_in_batches(conn, values, batch_size=1000):
"""IN-list queries in safe batches (SQL Server caps parameters)."""
frames = []
for i in range(0, len(values), batch_size):
batch = values[i:i + batch_size]
placeholders = ','.join(['?'] * len(batch))
sql = f"""
SELECT Account_ID AS account_id, Account_Name, Current_Balance
FROM Accounts
WHERE Account_ID IN ({placeholders})
"""
frames.append(pd.read_sql_query(sql, conn, params=batch))
return pd.concat(frames, ignore_index=True)
db_df = query_in_batches(conn, account_list)
db_df['account_id'] = db_df['account_id'].astype(str).str.strip()
print(f"Excel rows: {rows_in:,} | DB rows returned: {len(db_df):,}")
# 3. Safe merge: keep every Excel row, label match status, enforce shape
merged = pd.merge(
excel_df,
db_df,
on='account_id',
how='left',
indicator=True,
validate='one_to_one' # use 'one_to_many' only if fan-out is genuinely expected
)
# 4. Account for every row
counts = merged['_merge'].value_counts()
matched = counts.get('both', 0)
unmatched = counts.get('left_only', 0)
print(f"Matched: {matched:,} | Unmatched: {unmatched:,} | Total: {len(merged):,}")
assert matched + unmatched == rows_in, "Rows gained or lost in merge — investigate before using output!"
merged[merged['_merge'] == 'left_only'].drop(columns='_merge') \
.to_excel('EXCEPTIONS_not_in_database.xlsx', index=False)
merged.drop(columns='_merge').to_excel('accounts_with_data.xlsx', index=False)
Validation (control totals)
- Rows in vs. rows out: with
how='left'andvalidate='one_to_one',len(merged)must equalrows_inexactly. If you allowedone_to_many, rows out will exceed rows in — reconcile the difference to the expected fan-out, don’t shrug at it. - Matched + unmatched = total:
counts['both'] + counts['left_only'] == rows_in. Put all three numbers in the workpaper. - Amount tie-out:
excel_df['gl_balance'].sum()must equalmerged['gl_balance'].sum()— a left merge never changes left-side totals; if it did, rows duplicated. - validate= as tripwire: if
pd.mergeraisesMergeError, the database has duplicate keys. That’s a finding, not a nuisance — do not “fix” it by dropping duplicates without understanding why they exist.
Exceptions to surface
- Every row in
EXCEPTIONS_not_in_database.xlsx(_merge == 'left_only'): accounts on the Excel list the database doesn’t know about — typos, closed accounts, wrong system, or something worse. - Duplicate keys in the database extract (caught by
validate=ordb_df['account_id'].duplicated()): a reviewer must decide which record is authoritative. - Keys that only matched after normalization (dtype/whitespace/leading-zero fixes): note them so the source-system formatting issue gets fixed upstream.
- DB rows returned that exceed the distinct keys requested — a sign the key isn’t unique in the source table.