,

Joins and merges in plain English

11 min read
Featured image: Joins and merges, Python for analytics series

You have two CSVs that clearly belong together. Customers in one file. Orders in the other. In a spreadsheet you would reach for VLOOKUP. In SQL you would write a JOIN. In pandas the tool is pd.merge, and it is friendly right up until your row count quietly doubles and nobody notices until the board deck is locked.

This is Part 6 of Python for analytics, a series for people who already think in sheets or SQL and want pandas to feel like the same mental model with better repeatability. You already filtered and grouped in earlier parts. Now you combine tables without turning one customer into twelve phantom buyers.

What you’ll learn

  • How SQL join types map to pd.merge(..., how=...)
  • When to use on versus left_on / right_on
  • Why many-to-many merges explode rows (and how to spot it)
  • Optional validate= checks that fail loudly instead of silently
  • A customers + orders worked example with row-count discipline

Joins are not magic. They are matching rules.

Row-count story of a simple inner merge:

c6 merge rows

A join (or merge) answers one question: for each row on the left and each row on the right, when do we consider them the same entity and paste their columns together? The matching key might be customer_id, an email, or a composite of region plus account code. Everything else is policy about unmatched rows and duplicate keys.

If you came from the SQL series, you already know INNER, LEFT, RIGHT, and FULL OUTER. Pandas uses the same ideas with slightly different names. If you came from spreadsheets via From spreadsheets to real data, think of VLOOKUP as a clumsy left join that returns the first match and panics less helpfully when keys are wrong.

The skill that matters at work is not memorizing the keyword. It is predicting which rows survive and whether one input row can become many output rows. That prediction is how you catch bad keys before leadership sees a revenue spike that was really a Cartesian oops.

SQL join types in pandas language

Here is the map you will reuse constantly. Keep it next to your notebook until it is muscle memory.

SQL ideapandas how=Who survivesWorkplace use
INNER JOINinner (default)Only keys in both tablesOrders that have a known customer
LEFT JOINleftAll left rows; right fills or nullsAll customers, attach last order if any
RIGHT JOINrightAll right rows; left fills or nullsRare; usually flip tables and use left
FULL OUTER JOINouterKeys from either sideReconcile two lists and find mismatches

There is also a cross join pattern (every left row with every right row), which you almost never want for customer analytics. If your row count goes from thousands to millions with no filter, check whether you accidentally merged without a key or with a broken key of all nulls.

Diagram of merge join types inner left right outer

Picture two circles of keys. Inner keeps the overlap only. Left keeps the whole left circle and paints right attributes where they match. Outer keeps the union and leaves holes where one side has no partner. That picture is enough to brief a stakeholder who does not care about function names.

The core call: pd.merge

Most analytics merges look like this:

import pandas as pd

result = pd.merge(
    left_df,
    right_df,
    how="left",
    on="customer_id",
)

Example:

c6 how counts
merge how= row counts

Example output:

c6 merge table
Example output: left merge customers to orders

If both tables share the same key column name, on= is clean. If names differ, use left_on and right_on:

result = pd.merge(
    customers,
    orders,
    how="left",
    left_on="id",
    right_on="customer_id",
)

After a rename mismatch merge, you often keep both key columns. Decide which one is canonical, drop the other, and rename so the next person is not guessing whether id means customer or order.

Suffixes when both sides share column names

If both frames have a column called region, merge adds suffixes (default _x and _y). That is pandas telling you the columns conflicted. Prefer explicit names before the merge:

customers = customers.rename(columns={"region": "customer_region"})
orders = orders.rename(columns={"region": "order_region"})

result = pd.merge(customers, orders, how="left", on="customer_id")

Clear names beat default suffixes in every handoff. Future-you will thank present-you.

Keys, grain, and the many-to-many explosion

Join type is only half the story. The other half is multiplicity: is the key unique on the left, unique on the right, both, or neither?

  • One-to-one: each key appears at most once on each side. Safe and rare for real operational data.
  • One-to-many: one customer, many orders. Expected. Output rows equal order rows (for an inner join on customer_id), with customer columns repeated.
  • Many-to-many: the same key repeats on both sides. Every left match pairs with every right match. Three left rows and four right rows for key A become twelve output rows for A.

Many-to-many is not always wrong. Sometimes both tables are legitimately multi-row at that grain (for example, student-course enrollments joined to student-club memberships). In revenue work, many-to-many often means your key is incomplete: you needed customer_id plus brand, or you joined on email when emails are shared across accounts.

From Analytics foundations, remember grain: one row means one thing. After a merge, restate the grain. If you started with one row per customer and ended with one row per order, say so. If you thought you still had one row per customer, you will double-count revenue the moment you sum.

Worked example: customers and orders

We will build tiny tables you can type from memory. That is intentional. Small data makes join behavior obvious. Big data hides the same bugs under impressive file sizes.

import pandas as pd

customers = pd.DataFrame(
    {
        "customer_id": [1, 2, 3, 4],
        "name": ["Ada", "Ben", "Cara", "Dee"],
        "segment": ["Pro", "Pro", "Free", "Pro"],
    }
)

orders = pd.DataFrame(
    {
        "order_id": [101, 102, 103, 104, 105],
        "customer_id": [1, 1, 2, 2, 9],
        "amount": [40.0, 15.0, 22.0, 30.0, 99.0],
    }
)

print("customers", len(customers))
print("orders", len(orders))

Facts to keep in your head before any merge:

  • 4 customers, including Cara (id 3) with no orders and Dee (id 4) with no orders
  • 5 orders, including order 105 for customer_id 9 (orphan order; no customer row)
  • Ada and Ben each have two orders (one-to-many on customer_id)

Inner merge: only successful matches

inner = pd.merge(customers, orders, how="inner", on="customer_id")
print(inner)
print("rows", len(inner))

You should get 4 rows: Ada’s two orders and Ben’s two orders. Cara and Dee drop out. Order 105 drops out. Inner is the strict club: both sides must show up with a matching key.

Left merge: keep every customer

left = pd.merge(customers, orders, how="left", on="customer_id")
print(left)
print("rows", len(left))

You should get 6 rows: four order lines for Ada and Ben, plus Cara and Dee with nulls in order columns. That is the classic “customer list with optional order facts” pattern. Note the grain changed. You no longer have one row per customer for everyone who ordered more than once.

Outer merge: find the orphans

outer = pd.merge(customers, orders, how="outer", on="customer_id", indicator=True)
print(outer)
print(outer["_merge"].value_counts())

indicator=True adds a column that labels each row as both, left_only, or right_only. For reconciliation work, that column is gold. right_only is your orphan order 105. left_only is Cara and Dee. Filtering on those labels is how you build a Monday morning data quality checklist without a full platform project.

Outcome table for this toy dataset

Merge typeExpected rowsWho is missingGood for
inner4Cara, Dee, order 105Analysis of matched activity only
left (customers left)6order 105 onlyCustomer coverage; null orders = inactive
right (orders right)5Cara, DeeOrder facts with optional customer attributes
outer7nobody in the unionReconciling two systems

Print the expected count before you run the merge. If reality disagrees, stop. Do not average first and investigate later.

Row count checks before and after

Professional merge hygiene is boring and it saves careers. Use a short checklist every time tables touch:

def merge_with_checks(left, right, **kwargs):
    left_n = len(left)
    right_n = len(right)
    left_key = kwargs.get("on") or kwargs.get("left_on")
    print(f"left rows={left_n}, right rows={right_n}")
    print(f"left key nulls={left[left_key].isna().sum() if isinstance(left_key, str) else 'check manually'}")

    out = pd.merge(left, right, **kwargs)
    print(f"result rows={len(out)}")

    if kwargs.get("how", "inner") == "left" and isinstance(left_key, str):
        # One-to-many can grow; never shrink below left unique keys without reason
        print(f"left unique keys={left[left_key].nunique()}")
        print(f"result unique keys={out[left_key].nunique()}")
    return out

matched = merge_with_checks(
    customers,
    orders,
    how="left",
    on="customer_id",
    validate="one_to_many",  # fails if keys are not as assumed
)

Example:

c6 validate merge
merge validate indicator

The optional validate argument is underused. Pass "one_to_one", "one_to_many", "many_to_one", or "many_to_many". If reality violates your assumption, pandas raises an error. That is a feature. Silent wrong joins are how dashboards invent customers.

Also check null rates on the key before merging. Null keys do not match each other in a useful way for business keys. A blank customer_id on both sides is not “same customer.” Clean or drop null keys deliberately.

When VLOOKUP thinking hurts you

Spreadsheet muscle memory says: look up one value from a table. That is roughly a left join that returns a single column and, in many sheet tools, the first match only. Pandas will happily return every match. If your “lookup table” is not unique on the key, you will multiply rows and then wonder why average order value looks weird after a sum divided by a wrong denominator.

If you truly want one row per left key, enforce uniqueness on the right first. Aggregate orders to customer grain, then left-merge:

orders_by_customer = (
    orders.groupby("customer_id", as_index=False)
    .agg(
        order_count=("order_id", "count"),
        revenue=("amount", "sum"),
        last_order_id=("order_id", "max"),
    )
)

customer_summary = pd.merge(
    customers,
    orders_by_customer,
    how="left",
    on="customer_id",
    validate="one_to_one",
)

print(len(customers), len(customer_summary))  # should match

That pattern matches how many teams think in SQL too: aggregate the fact table to the dimension grain, then join. Part 5 of this series covered groupby. Merge after groupby is a standard workplace combo, not a clever trick.

Composite keys and almost-matches

Real companies rarely join on a single pretty id forever. You may need account_id plus brand, or store_id plus business_date. In pandas, pass a list:

merged = pd.merge(
    sales,
    targets,
    how="left",
    on=["store_id", "business_date"],
    validate="many_to_one",
)

If one side uses biz_date and the other uses business_date, align names first or use left_on/right_on lists of equal length. Partial matches are a frequent source of under-coverage: you join only on store and wonder why every date multiplies. When coverage looks low after a left join, sample the unmatched keys and ask whether the key is incomplete, mistyped, or truly absent.

Fuzzy matching on names (customer “Ada Lovelace” vs “A. Lovelace”) is a different sport. Do not pretend merge will solve identity resolution. Keep fuzzy work in an explicit pre-step with a review file, then merge on a resolved id. Your future self will not want to debug string distance inside a revenue join.

Common mistakes

  • Joining on the wrong grain. Order lines joined to monthly targets without a month key. Always name both grains in a sentence before you code.
  • Ignoring type mismatches. customer_id as int on one side and string on the other yields zero matches and a full column of nulls after a left join. Check dtypes and cast both sides.
  • Whitespace and case in string keys. "ADA " does not equal "ada". Strip and normalize before merge when keys are human-entered.
  • Summing after a one-to-many without re-aggregating. Customer attributes repeated on order rows make naive sums of customer-level fields explode.
  • Default inner join when you meant left. Missing customers disappear and your “inactive rate” looks artificially great.
  • Skipping validate. You assume one-to-many; the data is many-to-many; your laptop fans spin and your metrics lie.

Practice and next step

Take any two related exports you already have (CRM contacts and billing charges, tickets and accounts, campaigns and spend). Write the expected join type and expected approximate row count on paper first. Then merge in pandas with indicator=True and validate=. Compare paper to output.

When you are ready for messy reality, continue to Part 7 on missing data and dtypes. Nulls, empty strings, and bad date casts are the usual reason “my join returned nothing” after the logic was fine. For a broader map of paths on the site, visit the Learn hub.

Quick recap

  • pd.merge is SQL joins with pandas names: inner, left, right, outer.
  • Use on when names match; use left_on / right_on when they do not.
  • Many-to-many multiplies rows. Check uniqueness and restate grain after every merge.
  • Row counts before and after, plus optional validate and indicator, catch disasters early.
  • Prefer aggregate-then-merge when you need one row per entity, not one row per event.

Sources