Concat
Concat
Core Principle:
pd.concatbinds DataFrames along a specified axis (vertically stacking rows alongaxis=0or horizontally combining columns alongaxis=1) aligning on existing index labels without relational key matching.
Vertical: more rows
pd.concat([df1, df2], ignore_index=True)
pd.concat([df1, df2, df3], ignore_index=True)
pd.concat(list_of_frames, ignore_index=True)
ignore_index=True unless the index is meaningful. Without it you get duplicate index values (0,1,2,0,1,2), which then break .loc, silently change join behaviour, and make reset_index() necessary later anyway.
Columns are matched by name, not position:
gene af 0 TP53 0.1 0 BRCA1 0.2
Mismatched columns produce NaN:
gene 0 TP53 0 EGFR
join="inner" when you want strictness, but be aware it silently drops columns: check the result's shape.
Tracking the source
combined = pd.concat(
[df_batch1, df_batch2, df_batch3],
keys=["batch1", "batch2", "batch3"],
) # → MultiIndex with the key as level 0
combined = pd.concat(
{"batch1": df1, "batch2": df2}, # a dict works too
names=["batch", "row"],
)
combined.reset_index(level="batch") # key becomes a column
Or, more simply and often more usefully, add the label yourself:
frames = [pd.read_csv(p, sep="\t").assign(sample=p.stem) for p in paths]
combined = pd.concat(frames, ignore_index=True)
The assign version keeps you in flat-column land, which is easier to merge and group later. See MultiIndex for when the keys version is worth it.
Horizontal: more columns
pd.concat([df1, df2], axis=1)
This aligns on the index, which is the part that surprises people:
x y g1 1.0 NaN g2 2.0 3.0 g3 NaN 4.0
If you meant "glue these side by side positionally", reset both indices first: or, better, use merge on an explicit key so the intent is visible.
The performance rule
# BAD: quadratic; each concat copies everything so far
result = pd.DataFrame()
for path in paths:
result = pd.concat([result, pd.read_csv(path)])
# GOOD: one allocation
frames = [pd.read_csv(p) for p in paths]
result = pd.concat(frames, ignore_index=True)
Same lesson as np.append in Array Creation. With 500 per-sample files, the bad version is minutes and the good version is seconds.
df.append() was removed in pandas 2.0 precisely because it encouraged the bad pattern.
Dtype preservation
Concatenation can silently change dtypes:
- Categoricals with different categories → falls back to
object. Set the same categories on both first, or usepd.api.types.union_categoricals. int64+ a frame missing that column → NaN forcesfloat64. UseInt64if you need to keep integers.- Mixed numeric and string in the same column name →
object.
combined = pd.concat(frames, ignore_index=True)
combined.dtypes # ← check after concatenating
Bioinformatics examples
from pathlib import Path
# combine per-sample variant calls
frames = []
for vcf in Path("calls").glob("*.tsv"):
df = pd.read_csv(vcf, sep="\t", dtype=SCHEMA)
df["sample"] = vcf.stem
frames.append(df)
variants = pd.concat(frames, ignore_index=True)
# combine per-chromosome results
by_chrom = [pd.read_parquet(f"results/{c}.parquet") for c in CHROMS]
all_results = pd.concat(by_chrom, ignore_index=True)
# stack DE results from several contrasts for a faceted plot
de_all = pd.concat(
{"treated_vs_control": de1, "drug_vs_control": de2},
names=["contrast", "row"],
).reset_index(level="contrast")
sns.relplot(data=de_all, x="log2FoldChange", y="neglog10p", col="contrast")
# combine chunked processing results
chunks = [c.query("qual > 30") for c in pd.read_csv(big, sep="\t", chunksize=100_000)]
filtered = pd.concat(chunks, ignore_index=True)
The per-sample loading pattern in the first example is one of the most common things you will write in bioinformatics. Note the df["sample"] = vcf.stem: without it, once concatenated, you cannot tell which row came from which sample, and there is no recovering it.
concat vs merge: deciding
| Question | Answer |
|---|---|
| Same columns, more rows? | concat(axis=0) |
| Different columns for the same entities, matched by a key? | merge |
| Different columns, already index-aligned? | concat(axis=1): but merge is clearer |
| Combining per-sample or per-chromosome outputs? | concat(axis=0) |
| Adding annotation? | merge |
When in doubt use merge: the explicit key makes the intent readable and lets you pass validate=.
Common mistakes
concatin a loop. Quadratic.- Forgetting
ignore_index=True→ duplicate index values. axis=1aligning on index when you meant positional.- Categorical dtype degrading to object.
- Silently missing columns filled with NaN: check
df.isna().sum()after. - Not recording the source file/sample.
- Assuming column order matters. It does not; names do.
- Using
concat(axis=1)wheremergewould document the intent.
See also
Merging and Joining · DataFrame · Reading and Writing Data · Categorical dtype · Reshaping and Stacking
Exercise
Write combine(blocks), where blocks is a list of (sample, df) pairs read
from several per-sample tables. The tables share the column gene, most also
have count, and the column order differs between them. Stack them into one
frame with a fresh index, recording which sample each row came from in a
sample column, since that information is unrecoverable once the frames are
stacked.
Return the lines rows=<n> for the combined row count, cols=<names> for the
combined column names as a comma-separated, alphabetically sorted string, then
one <sample> rows=<n> line per block in the order the blocks were given, then
missing_count=<n> for the number of combined rows whose count is missing,
which includes every row from a block that had no count column at all: and
finally top=<sample> for the block contributing the most rows, ties broken
alphabetically, or top=none when every block is empty.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | #S1 gene count TP53 1204 BRCA1 455 #S2 count gene 3021 EGFR 880 KRAS 1760 MYC #S3 gene GAPDH | rows=6 cols=count,gene,sample S1 rows=2 S2 rows=3 S3 rows=1 missing_count=1 top=S2 |
| 2 | stdin | #S1 gene count TP53 1204 #S2 gene count | rows=1 cols=count,gene,sample S1 rows=1 S2 rows=0 missing_count=0 top=S1 |
| 3 | stdin | #A gene count TP53 10 BRCA1 20 #B gene count EGFR 30 KRAS 40 | rows=4 cols=count,gene,sample A rows=2 B rows=2 missing_count=0 top=A |
| 4 | stdin | #S1 gene count #S2 gene count | rows=0 cols=count,gene,sample S1 rows=0 S2 rows=0 missing_count=0 top=none |
Hints · 2
01Hint
Build the list of labelled frames first, then call pd.concat once with ignore_index=True; concatenating inside the loop is quadratic.
02Hint
concat matches columns by name, not position, and fills a column a block does not have with NaN.