Skip to content
C Codeloom
Pandas

Pandas Merge, Join, and Concat Explained

Pick the right pandas combining function with clear examples of inner, outer, left, right joins, concat, and the gotchas that cause silent bugs.

·5 min read · By Codeloom
Beginner 10 min read

What you'll learn

  • When to use merge, join, or concat
  • How inner, outer, left, and right joins differ
  • Validating one-to-one and one-to-many relationships
  • Diagnosing row blow-ups from duplicate keys
  • Concatenating frames vertically and horizontally safely

Prerequisites

  • Familiar with how APIs work

What and Why

Real datasets almost never live in a single table. You combine customers with orders, sessions with users, events with metadata. Pandas gives you three main tools to do this: merge, join, and concat. They overlap, the docs explain each in isolation, and people pick whichever they remember first. That leads to subtle bugs.

The goal here is a clear mental map: when to use which, and which gotchas blow up your row count if you are not careful.

Mental Model

  • merge: SQL-style joins on column values. Most flexible, most common.
  • join: shortcut for merging on indexes. Convenient when both frames are already indexed by the key.
  • concat: stack frames vertically (more rows) or horizontally (more columns). No key matching.
merge:    [A]  +  [B]   --on key-->   matched rows side by side
join:     [A indexed by k]  +  [B indexed by k]  -->  same as merge on index
concat (axis=0):    [A]  +  [B]  -->  stacked rows
concat (axis=1):    [A]  +  [B]  -->  stacked columns (aligned on index)
Three combining patterns

If you find yourself reaching for join, ask whether merge(left_index=True, right_index=True) would be more explicit. It usually is.

Hands-on Example

import pandas as pd

customers = pd.DataFrame({
    "customer_id": [1, 2, 3, 4],
    "name": ["Ada", "Brij", "Cleo", "Dan"],
})

orders = pd.DataFrame({
    "order_id": [10, 11, 12, 13],
    "customer_id": [1, 2, 2, 5],
    "amount": [99, 25, 40, 15],
})

Four join types on the same data:

# inner: rows that exist in BOTH
pd.merge(customers, orders, on="customer_id", how="inner")

# left: all customers, orders matched where possible
pd.merge(customers, orders, on="customer_id", how="left")

# right: all orders, customers matched where possible
pd.merge(customers, orders, on="customer_id", how="right")

# outer: union of both
pd.merge(customers, orders, on="customer_id", how="outer")

Pick inner when you only want matched rows. Use left for “enrich this table with extras.” Use outer for reconciliation (finding what is in one table but not the other).

The indicator=True flag is gold for debugging:

pd.merge(customers, orders, on="customer_id", how="outer", indicator=True)
# adds a _merge column with values: 'left_only', 'right_only', or 'both'

You can immediately filter to see who is missing on each side.

The Row Blow-up Trap

The single most common merge bug is duplicate keys causing rows to multiply. If customers has two rows for customer_id=1 and orders has three rows for customer_id=1, you get six combined rows. That is a cartesian explosion inside the merge.

Use validate to assert the relationship you expect:

pd.merge(customers, orders, on="customer_id", validate="one_to_many")
# raises if customers has duplicates on customer_id

Valid values: one_to_one, one_to_many, many_to_one, many_to_many. The first three protect you from accidental row multiplication. Use them.

Concat

concat stacks frames without matching keys. Vertical stacking is the most common use.

q1 = pd.read_csv("sales_q1.csv")
q2 = pd.read_csv("sales_q2.csv")
year = pd.concat([q1, q2], ignore_index=True)

ignore_index=True resets the row index so you do not end up with duplicate index values.

Horizontal stacking aligns on the index. Watch for misalignment:

features = pd.concat([numeric_df, categorical_df], axis=1)

If indices do not match, you get NaNs in surprising places. Sort or reindex before concatenating.

A common mistake is using concat when you meant merge. Concat does no key matching. If two frames have the same customer_id column, vertical concat will stack them; it will not join them.

Trade-offs

  • Merge is the most general but slowest on huge data. A 10M-row by 10M-row inner merge is expensive.
  • Index joins are faster. If you join the same keys repeatedly, set them as the index once and use join.
  • Concat duplicates the underlying data. Avoid concat in tight loops; collect frames in a list and concat once.
  • Outer merges can blow up memory. A full outer between two large frames with many unmatched keys can produce many NaN rows.

Practical Tips

  • Always pass validate on production merges. It costs nothing and catches duplicate-key explosions early.
  • Check row counts before and after merge. A merge should produce a known shape. Print before, after and assert.
  • Use indicator=True when debugging missing rows. It is the fastest way to see which side is missing what.
  • Set explicit suffixes for overlapping column names. _left, _right get confusing in pipelines.
  • Prefer merge over join for clarity. join saves a few characters but obscures what is being matched.
  • Reset indexes after concat when you do not need the original. Stale indexes are a silent source of alignment bugs downstream.
  • For repeated lookups, set the lookup key as the index and use loc or map. It is faster than a fresh merge each time.

A diagnostic trick when results look wrong: compute df["customer_id"].nunique() before and after the merge. If it changes when you did not expect it to, you have a key problem.

Wrap-up

Merge, join, and concat each do one job, but the failure modes overlap and the bugs are quiet. Default to merge with an explicit how and a validate clause. Use concat only for stacking frames that share a shape. Run quick row-count assertions around every combine step. Once these habits settle in, combining tables becomes routine rather than the source of every other bug.