Fix pandas concat Columns Misaligned
Twelve monthly workbooks, one combined frame, and twice as many columns as expected:
>>> combined = pd.concat(frames, ignore_index=True)
>>> combined.columns.tolist()
['Region', 'region', 'Units', 'units ', 'Value', 'Total Value', 'Share']
>>> combined["Region"].isna().sum()
8400 # every row from the files that spelled it 'region'
Nothing has been lost — the values are all present — but they are spread across near-duplicate columns, so every aggregate is wrong and every filter misses most of the data.
Root Cause
pd.concat aligns on exact column labels. "Region", "region" and "Region " are three different labels, so three columns appear and each row populates only the one its source file used. The same applies to a genuine rename between periods: "Value" in January and "Total Value" in February are unrelated as far as alignment is concerned.
The default join="outer" keeps every column from every frame and fills the gaps with NaN. That is the correct behaviour for a general-purpose concatenation and the wrong outcome for a set of files that are supposed to share a schema — which is why the fix is to normalise the schemas before concatenating, not to change how concat joins.
Minimal Diagnostic
Compare the schemas across the files before combining anything.
# pip install "pandas>=2.2,<3" "openpyxl>=3.1,<4"
from collections import Counter
from pathlib import Path
import pandas as pd
FOLDER = Path("data/monthly")
def schema_report(folder: Path, pattern: str = "*.xlsx") -> pd.DataFrame:
"""List which columns appear in which files, so drift is visible at a glance."""
files = sorted(folder.glob(pattern))
if not files:
raise FileNotFoundError(f"No files matching {pattern} in {folder}")
per_file = {}
counts: Counter[str] = Counter()
for path in files:
try:
columns = list(pd.read_excel(path, nrows=0).columns)
except Exception as exc:
raise RuntimeError(f"Could not read {path.name}: {exc}") from exc
per_file[path.name] = columns
counts.update(columns)
rows = []
for column, seen in counts.most_common():
rows.append({
"column": repr(column), # repr exposes trailing spaces
"files_with_it": seen,
"of_total": len(files),
"missing_from": [n for n, cols in per_file.items() if column not in cols][:3],
})
return pd.DataFrame(rows)
if __name__ == "__main__":
print(schema_report(FOLDER).to_string(index=False))
column files_with_it of_total missing_from
'Region' 9 12 [mar.xlsx, apr.xlsx, may.xlsx]
'region' 3 12 [jan.xlsx, feb.xlsx, jun.xlsx]
'Units' 12 12 []
'units ' 0 12 []
repr on the column name is what makes a trailing space visible; printed plainly, 'units ' and 'units' look identical and the report is useless.
The Fix: Normalise, Then Concatenate
Give every frame the same header vocabulary before combining, and record which file each row came from.
# pip install "pandas>=2.2,<3" "openpyxl>=3.1,<4"
from pathlib import Path
import pandas as pd
CANONICAL = {
"region": "region",
"area": "region", # known historical alias
"units": "units",
"qty": "units",
"value": "value",
"total value": "value",
"share": "share",
}
def normalise_columns(frame: pd.DataFrame) -> pd.DataFrame:
"""Lower-case, strip, collapse whitespace, then map known aliases."""
cleaned = (
pd.Index(frame.columns)
.astype(str)
.str.replace(" ", " ", regex=False) # non-breaking space from Excel
.str.strip()
.str.lower()
.str.replace(r"\s+", " ", regex=True)
)
frame = frame.copy()
frame.columns = [CANONICAL.get(name, name) for name in cleaned]
return frame
def combine(folder: Path, pattern: str = "*.xlsx") -> pd.DataFrame:
"""Read and concatenate a folder of workbooks with aligned schemas."""
frames = []
for path in sorted(folder.glob(pattern)):
frame = normalise_columns(pd.read_excel(path))
frame["source_file"] = path.name # provenance for every row
frames.append(frame)
if not frames:
raise FileNotFoundError(f"No files matching {pattern} in {folder}")
return pd.concat(frames, ignore_index=True)
if __name__ == "__main__":
combined = combine(Path("data/monthly"))
print(combined.columns.tolist())
print(f"{len(combined):,} row(s) from {combined['source_file'].nunique()} file(s)")
The alias map is the part that must be maintained deliberately. An unmapped new spelling still creates its own column, which is exactly right: it should be visible and require a decision, not be guessed at by fuzzy matching that might merge two genuinely different measures.
source_file costs one column and makes every later question answerable — which file introduced the outlier, which period is missing, which workbook had the alias.
Variant Fix 1: Enforce the Schema with an Inner Join
When only the shared columns matter, join="inner" keeps the intersection and drops the rest:
# pip install "pandas>=2.2,<3"
import pandas as pd
combined = pd.concat(frames, join="inner", ignore_index=True)
Use it with care. Silently dropping a column present in eleven of twelve files loses real data, so pair it with a report of what was discarded:
# pip install "pandas>=2.2,<3"
all_columns = set().union(*(set(f.columns) for f in frames))
shared = set.intersection(*(set(f.columns) for f in frames))
dropped = sorted(all_columns - shared)
if dropped:
print(f"inner join dropped: {dropped}")
Variant Fix 2: Reindex Each Frame to a Fixed Schema
The strictest version — declare the schema once and force every frame into it. Missing columns appear as NaN, extra columns raise.
# pip install "pandas>=2.2,<3"
import pandas as pd
SCHEMA = ["region", "units", "value", "share"]
def conform(frame: pd.DataFrame, schema: list[str], source: str) -> pd.DataFrame:
"""Force a frame into the declared schema, reporting anything unexpected."""
extra = [c for c in frame.columns if c not in schema and c != "source_file"]
if extra:
raise ValueError(f"{source}: unexpected column(s) {extra} — update the schema or the alias map")
missing = [c for c in schema if c not in frame.columns]
if missing:
print(f"warning: {source} is missing {missing}")
return frame.reindex(columns=schema) # order fixed, gaps filled with NaN
if __name__ == "__main__":
conformed = [conform(normalise_columns(f), SCHEMA, name) for name, f in loaded.items()]
combined = pd.concat(conformed, ignore_index=True)
Raising on unexpected columns turns schema drift into a build failure rather than a silent widening. The declarative version of this idea — types and ranges as well as names — is validating document data with schemas.
Variant Fix 3: Misalignment Caused by the Index, Not the Columns
ignore_index=True matters more than it looks. Without it, concatenating frames that each start at index 0 produces repeated index values, and any later join, reindex or loc behaves unpredictably:
# pip install "pandas>=2.2,<3"
import pandas as pd
bad = pd.concat(frames) # index: 0,1,2,0,1,2,0,1,2 …
good = pd.concat(frames, ignore_index=True) # index: 0,1,2,3,4,5,6,7,8 …
print(bad.index.is_unique, good.index.is_unique)
If a header row was itself misread, the columns will be wrong before concat ever runs — that failure is diagnosed in fix unnamed columns in pandas read_excel.
Verification
Assert on the combined frame: no near-duplicate labels, no column that is mostly empty, and a row count that adds up.
# pip install "pandas>=2.2,<3"
import pandas as pd
def assert_aligned(combined: pd.DataFrame, sources: dict[str, int],
max_null_pct: float = 20.0) -> None:
"""Fail on duplicate-ish columns, sparse columns, or a lost row."""
normalised = [str(c).strip().lower() for c in combined.columns]
duplicates = {c for c in normalised if normalised.count(c) > 1}
assert not duplicates, f"near-duplicate column(s) survived: {sorted(duplicates)}"
expected_rows = sum(sources.values())
assert len(combined) == expected_rows, (
f"row count {len(combined):,} != sum of sources {expected_rows:,}"
)
sparse = {
column: round(combined[column].isna().mean() * 100, 1)
for column in combined.columns
if combined[column].isna().mean() * 100 > max_null_pct
}
assert not sparse, f"column(s) mostly empty — alignment likely failed: {sparse}"
print(f"{len(combined):,} row(s), {len(combined.columns)} column(s), all aligned")
if __name__ == "__main__":
assert_aligned(combined, {"jan.xlsx": 700, "feb.xlsx": 680})
The sparse-column check is the one that catches partial misalignment — the case where nine files agree, three do not, and the resulting column is 25% empty. A row-count check alone passes that scenario happily.
FAQ
Should I use concat or merge here?concat stacks rows from files with the same shape; merge joins columns on a key. Twelve monthly extracts of the same report are a concat. Combining a customer list with an order list is a merge — and its own overlapping-column trap is covered in fix pandas merge overlapping columns.
Is fuzzy matching of column names a good idea?
No. value and net value are similar strings and different measures. An explicit alias map fails loudly on a new name, which is what you want; fuzzy matching fails silently and plausibly.
Why do dtypes change after concatenating?
Combining an int64 column with one containing NaN promotes to float64, and combining category with object produces object. Set dtypes after the concat, or use nullable Int64 throughout.
How do I keep the original file order?
Sort the file list explicitly — sorted(folder.glob(...)) is lexicographic, so jan, feb, mar sorts as apr, aug, dec. Sort by a parsed date from the filename when order matters.
Can I concatenate without loading every file at once? Yes: write each normalised frame to a Parquet dataset directory and read the directory back as one frame, or append to a single CSV. The streaming techniques in handling large CSV files with chunking apply directly.
Catching Drift at the Source
The alias map fixes today's spellings; it does nothing about the next one. A short weekly report closes that gap: read only the header row of each new file, compare the set against the canonical schema, and print anything unrecognised.
It costs milliseconds per file and it surfaces a rename the week it happens rather than the month a total looks wrong. Where the files come from a system you can influence, that report is also the evidence needed to ask for the header to be stabilised, which is the only fix that removes the problem rather than absorbing it.
Related
- Merging Multiple Spreadsheets — the full multi-file workflow
- Fix pandas Merge Overlapping Columns — the join-side equivalent of this problem
- Fix Unnamed Columns in pandas read_excel — headers that were wrong before concat ran
- Cleaning Messy CSV Data with pandas — normalising values as well as names
- Validating Document Data with Schemas — declaring the schema instead of inferring it
Part of Merging Multiple Spreadsheets.