
Handles the annoying work of merging CSV and Excel files when columns don't match up perfectly. Does fuzzy matching on column names (so "Email", "e-mail", and "email_address" get unified), deduplicates based on your primary key, and flags conflicts when the same record appears with different values across files. Spits out a detailed report showing what got merged, what got deduplicated, and what needs manual review. The conflict resolution options are solid: keep first, keep last, keep longest value, or flag for review. Honestly most useful when you're combining contact lists or data exports from different systems and don't want to manually reconcile column schemas. Saves the tedious pandas boilerplate you'd otherwise write yourself.
npx -y skills add onewave-ai/claude-skills --skill csv-excel-merger --agent claude-codeInstalls into .claude/skills of the current project.
Combine tabular files into one clean output without silently losing, duplicating, or corrupting rows.
Copy this checklist and track progress:
- [ ] 1. Profile inputs
- [ ] 2. Choose append vs join
- [ ] 3. Map columns and normalize keys
- [ ] 4. Merge and resolve conflicts
- [ ] 5. Verify row math
- [ ] 6. Write output and report
Profile inputs. Run the bundled profiler first; it reports encoding, delimiter, rows, headers, candidate keys, and header overlap without changing anything:
python scripts/profile_inputs.py file1.csv file2.xlsx
Excel files are profiled per sheet. Confirm with the user which sheets count if a workbook has more than one.
Choose the operation. This is the decision that most often goes wrong:
pd.concat, then dedupe.pd.merge on a key.Map columns and normalize keys. Build an explicit {original: unified} rename map per file (see references/merge_strategies.md for common variants) and show it to the user when any match is fuzzy. Normalize key columns before dedupe or join: strip whitespace, lowercase emails, strip non-digits from phones, unify date formats. Without this, A@x.com and a@x.com survive as two people.
Merge. Read every file with dtype=str so IDs, ZIP codes, and phone numbers keep leading zeros, then convert specific columns afterward.
import pandas as pd
frames = []
for path, rename in [("jan.csv", {"E-mail": "email"}), ("feb.xlsx", {"Email Address": "email"})]:
df = (pd.read_excel(path, dtype=str) if path.endswith((".xlsx", ".xls"))
else pd.read_csv(path, dtype=str, encoding="utf-8-sig", # use the profiler's encoding
keep_default_na=False))
df = df.rename(columns=rename)
df["email"] = df["email"].str.strip().str.lower()
df["source_file"] = path # lineage for every row
frames.append(df)
combined = pd.concat(frames, ignore_index=True, sort=False)
# Blank keys are not duplicates of each other: set them aside before deduping.
has_key = combined["email"].fillna("") != ""
# Later files win: list the most recent source last, then keep="last".
deduped = combined[has_key].drop_duplicates(subset=["email"], keep="last")
no_key = combined[~has_key]
merged = pd.concat([deduped, no_key], ignore_index=True)
For a join, make pandas enforce the relationship you expect so a duplicate key raises instead of multiplying rows:
out = pd.merge(contacts, deals, on="email", how="left",
validate="one_to_one", indicator=True)
unmatched = out[out["_merge"] == "left_only"]
Conflict strategies (keep first/last/most complete, combine fields, flag for review) are in references/merge_strategies.md.
Verify before reporting. Never hand back a merge without checking it:
rows_in = sum(len(f) for f in frames)
assert len(merged) > 0, "merge produced an empty frame"
assert len(merged) <= rows_in, "more rows out than in: check the join keys"
assert deduped["email"].is_unique, "duplicate keys remain after dedupe"
print(f"in={rows_in} out={len(merged)} removed={rows_in - len(merged)} blank_keys={len(no_key)}")
print(merged["source_file"].value_counts())
Spot-check three removed duplicates by hand against the source files; the asserts prove the math, not that the right row won.
Write output and report. Use the layout in references/output_template.md.
to_csv(path, index=False, encoding="utf-8-sig") (the BOM makes Excel read accents correctly).to_excel(path, index=False) with openpyxl installed. A sheet holds at most 1,048,576 rows; split or use CSV/Parquet beyond that.conflicts_review.csv or unmatched.csv when those sets are non-empty.Current pandas is 3.x (Python 3.11+). Differences that affect merges:
str dtype, not object. Check pd.api.types.is_string_dtype(col) instead of dtype == object.df[col][mask] = x never updates df (pandas only warns); use df.loc[mask, col] = x..dt.as_unit("ns") before casting to integers if something downstream expects nanoseconds.pd.read_excel(..., engine="calamine") (needs python-calamine) reads large workbooks much faster than openpyxl.The code in this skill also runs on pandas 2.2.
validate= catches it.dtype=str turns 01234 into 1234.1.23E+15 or dates already reformatted in the source file cannot be recovered by pandas; flag them.skiprows= or header=.é artifacts. The profiler reports the encoding per file.chunksize= or use Polars/DuckDB, and dedupe with a key set instead of loading everything into memory.