Combine DataFrames in Python
Learn to join, merge, and concatenate DataFrames in pandas with hands-on examples, troubleshooting tips, and clear guidance on choosing the right method for your data analysis tasks.
Focus: join, merge, and concatenate dataframes
You've wrangled one DataFrame, cleaned it, sliced it, and summarized it. But real data analysis rarely lives in a single table. Customer data sits in one file, order history in another, and product details in a third. The moment you need to answer a question that spans those tables, you hit a wall: how do you combine dataframes in Python? Without a solid grip on join, merge, and concatenate, you'll either copy-paste data into spreadsheets or write slow, brittle loops. This lesson gives you the mental model and hands-on skills to combine DataFrames confidently — the exact tools used in every serious pandas workflow.
The problem this lesson solves
When your analysis requires data from multiple sources, you have two fundamental operations: stacking rows and joining columns. The pain points are familiar:
- You have sales data for January and February in separate files, and you need one table with all rows.
- You have a customer table and an orders table, and you need to attach customer names to each order.
- You're about to merge on a column that has slightly different names — and you're not sure whether pandas will crash or silently produce garbage.
Without knowing the right tool, you might try to loop over rows and manually look up values in dictionaries — slow, error-prone, and unmaintainable. Or you might attempt a merge and end up with a Cartesian explosion — thousands of unintended row combinations.
The pandas library solves this elegantly with three main functions: concat(), merge(), and join(). Each addresses a specific shape of problem. This lesson strips away the confusion and gives you a decision framework you'll use for every future analysis.
Core concept / mental model
Think of DataFrames as tables in a relational database. Combining them is like performing SQL JOIN or UNION operations. Here's the analogy that clarifies the three tools:
concat()is stacking blocks. You place one DataFrame on top of another (or side by side) — like piling up sheets of paper. It matches rows or columns by position, not by a shared key.merge()is a database join. You combine columns from two DataFrames by matching values in a shared column (or multiple columns). It's the Swiss Army knife for relational-style joins: inner, left, right, outer.join()is a convenient alias formerge()— mostly used when joining on the index (row labels) rather than a column.
Here's a quick visual summary:
| Operation | Mental model | Typical use case |
|---|---|---|
concat() |
Stacking blocks | Combining weekly reports that share identical columns |
merge() |
Database join on a key column | Attaching customer names to orders |
join() |
Merge on index | Combining tables that share a row label |
Definitions to keep handy:
- Key: the column (or index) you match on.
- Join type: how to handle rows that appear in one table but not the other (
inner,left,right,outer). - Cartesian product: every row from one table paired with every row from the other — dangerous if keys have duplicates.
Once you internalize this, every combination task becomes a matter of answering three questions: Do I need to stack rows or attach columns? Am I matching on a column or the index? Do I want to keep all rows or only matches?
How it works step by step
1. Concatenating rows with concat()
When your DataFrames have the same columns (or you want to stack them along axis 0), concat() is your tool. The function takes a list of DataFrames and stacks them vertically by default.
import pandas as pd
# Two monthly sales tables
jan = pd.DataFrame({
"region": ["North", "South"],
"revenue": [500, 300]
})
feb = pd.DataFrame({
"region": ["North", "West"],
"revenue": [700, 450]
})
combined = pd.concat([jan, feb], ignore_index=True)
print(combined)
Output:
region revenue
0 North 500
1 South 300
2 North 700
3 West 450
Notice that the original indices are retained unless you pass ignore_index=True. That parameter resets the index to a clean 0..n-1 sequence — often what you want.
2. Merging on a column with merge()
When you need to attach columns from a second table based on a shared key, use merge(). By default, it performs an inner join — only rows with matching keys are kept.
customers = pd.DataFrame({
"customer_id": [1, 2, 3],
"name": ["Alice", "Bob", "Charlie"]
})
orders = pd.DataFrame({
"customer_id": [1, 1, 3],
"order_total": [99.5, 57.0, 120.0]
})
full_orders = pd.merge(customers, orders, on="customer_id")
print(full_orders)
Output:
customer_id name order_total
0 1 Alice 99.5
1 1 Alice 57.0
2 3 Charlie 120.0
Note that customer 2 had no orders, so they're dropped in an inner join. To keep all customers, you'd use how="left".
3. Joining on the index with join()
The join() method is a convenience for merging on the index. This is handy when row labels carry meaning (e.g., dates, IDs) and you don't want to reset them.
ratings = pd.DataFrame({
"avg_rating": [4.5, 4.7, 4.2]
}, index=["TT101", "TT102", "TT103"])
prices = pd.DataFrame({
"price": [29.99, 49.99, 39.99]
}, index=["TT101", "TT102", "TT104"])
combined = ratings.join(prices, how="left")
print(combined)
Output:
avg_rating price
TT101 4.5 29.99
TT102 4.7 49.99
TT103 4.2 NaN
The TT103 rating gets NaN for price because no matching row exists in prices.
Hands-on walkthrough
Let's combine everything with a realistic scenario: you're a data analyst at an e-commerce company. You have three dataframes:
customers:customer_id,name,signup_dateorders:order_id,customer_id,order_totalorder_items:order_id,product_id,quantity
Your goal: build a single DataFrame with each order line, the customer name, and the product ID.
Step 1: Load your data
import pandas as pd
customers = pd.DataFrame({
"customer_id": [1, 2, 3, 4],
"name": ["Alice", "Bob", "Charlie", "Diana"],
"signup_date": ["2024-01-15", "2024-02-10", "2024-03-01", "2024-03-20"]
})
orders = pd.DataFrame({
"order_id": [101, 102, 103, 104],
"customer_id": [1, 2, 1, 3],
"order_total": [99.5, 150.0, 57.0, 120.0]
})
order_items = pd.DataFrame({
"order_item_id": [1, 2, 3, 4, 5],
"order_id": [101, 101, 102, 103, 104],
"product_id": ["P001", "P002", "P001", "P003", "P002"],
"quantity": [1, 2, 1, 3, 2]
})
Step 2: Merge orders with customers
We want the customer name on every order. Since we need all orders (even if a customer is missing), use a left join.
orders_with_customers = pd.merge(
orders, customers, on="customer_id", how="left"
)
print(orders_with_customers)
Output:
order_id customer_id order_total name signup_date
0 101 1 99.5 Alice 2024-01-15
1 102 2 150.0 Bob 2024-02-10
2 103 1 57.0 Alice 2024-01-15
3 104 3 120.0 Charlie 2024-03-01
Step 3: Merge item details with orders
Now join the order IDs with the item lines to get product information on each line.
full_order_items = pd.merge(
order_items, orders_with_customers, on="order_id", how="left"
)
print(full_order_items)
Output:
order_item_id order_id product_id quantity customer_id order_total name signup_date
0 1 101 P001 1 1 99.5 Alice 2024-01-15
1 2 101 P002 2 1 99.5 Alice 2024-01-15
2 3 102 P001 1 2 150.0 Bob 2024-02-10
3 4 103 P003 3 1 57.0 Alice 2024-01-15
4 5 104 P002 2 3 120.0 Charlie 2024-03-01
Step 4: Add revenue per line (bonus)
You might also compute a total price per line. But that requires product prices — another merge. The pattern repeats.
prices = pd.DataFrame({
"product_id": ["P001", "P002", "P003"],
"price": [20.0, 15.5, 10.0]
})
final = pd.merge(full_order_items, prices, on="product_id", how="left")
final["line_revenue"] = final["quantity"] * final["price"]
print(final.head(3))
Output:
order_item_id order_id product_id quantity customer_id order_total name signup_date price line_revenue
0 1 101 P001 1 1 99.5 Alice 2024-01-15 20.0 20.0
1 2 101 P002 2 1 99.5 Alice 2024-01-15 15.5 31.0
2 3 102 P001 1 2 150.0 Bob 2024-02-10 20.0 20.0
This pipeline — sequential merge() calls — is the foundation of almost every relational data analysis in pandas.
Pro tip: Always inspect the shape of your DataFrame after a merge. If the row count jumps unexpectedly, you probably have duplicate keys.
Compare options / when to choose what
Here's a quick decision table to help you pick the right tool:
| Scenario | Recommended tool | Why |
|---|---|---|
| Stack rows from two tables with the same columns | concat() |
No key needed; simple stacking |
| Attach a column based on a shared column (like SQL) | merge() |
Full control over join type (inner, left, right, outer) |
| Combine tables with a meaningful index | join() |
Cleaner syntax when merging on index |
| Join on multiple columns | merge() with on=["col1", "col2"] |
Matches all keys simultaneously |
| Stack columns side by side | concat(axis=1) |
Useful for merging columns positionally |
Variations to consider
- SQL-style joins: If you're coming from SQL, think of
merge()as yourJOINclause. Thehowparameter maps toinner,left,right,outer. - Manual iteration: You could loop through rows and use dictionaries, but that's slow and error-prone. Avoid unless you have millions of rows and need to optimize with vectorized operations.
- Third-party tools: For very large datasets, consider
dask.dataframeorpolarsfor out-of-core merging. But the pandas syntax translates directly.
Troubleshooting & edge cases
Case 1: Column name mismatch
Your two DataFrames have the same data but different column names (CustomerID vs customer_id). If you call merge() with on="customer_id" on DataFrames that both have that column, it works. But if one has customer_id and the other has CustomerID, pandas raises a ValueError:
ValueError: can not merge DataFrame with instance of type <class 'NoneType'>
Fix: Use the left_on and right_on parameters:
merged = pd.merge(
customers, # has 'customer_id'
old_orders, # has 'CustomerID'
left_on="customer_id",
right_on="CustomerID",
how="left"
)
Case 2: Duplicate keys causing row explosion
If customer_id appears multiple times in both DataFrames, you'll get a Cartesian product — every pair of duplicate keys combined. Check your data for duplicates before merging.
# Detect duplicate keys
print(customers['customer_id'].duplicated().sum())
print(orders['customer_id'].duplicated().sum())
Fix: Clean duplicates or use validate parameter:
pd.merge(customers, orders, on="customer_id", validate="one_to_many")
validate='one_to_many' raises an error if the merge would produce a many-to-many result.
Case 3: Index alignment surprise with concat()
When concatenating with axis=1, pandas aligns rows by index labels — not by position. If your indices don't match, you'll get NaN values.
left = pd.DataFrame({"a": [1, 2]}, index=[0, 1])
right = pd.DataFrame({"b": [3, 4]}, index=[1, 2])
print(pd.concat([left, right], axis=1))
Output:
a b
0 1.0 NaN
1 2.0 3.0
2 NaN 4.0
Fix: Reset indices before concatenating, or use ignore_index=True (works only along axis 0). For axis=1, you need to reset the index manually:
pd.concat([left.reset_index(drop=True), right.reset_index(drop=True)], axis=1)
Case 4: Missing keys with NaN rows
When using an outer join, rows without matches get NaN in the missing columns. That's expected — don't panic.
full = pd.merge(customers, orders, on="customer_id", how="outer")
print(full)
Case 5: Type mismatches on key columns
If customer_id is int in one DataFrame and str ("1") in the other, the merge won't match. Cast them to the same type first:
orders['customer_id'] = orders['customer_id'].astype(str)
Or better, use pd.to_numeric or astype consistently.
What you learned & what's next
You've just mastered the three pillars of combining DataFrames in pandas:
concat()for stacking rows or columns when position matters.merge()for relational joins on shared columns — the workhorse of data analysis.join()as a convenient shortcut on indexes.
You can now explain the core idea behind each operation and complete practical exercises that attach, stack, and blend dataframes with confidence. You also know how to troubleshoot common pitfalls like duplicate keys, column mismatches, and type mismatches.
This is a cornerstone skill. Next, you'll dive into data aggregation and grouping — using groupby() to summarize combined datasets, which is the natural next step after you've brought all your data together. You'll learn how to compute totals, averages, and counts by category, and you'll see how the merged frames you build here become the foundation for powerful insights.
Remember: every merge is a tiny decision. Ask yourself: What shape do I need? What key am I using? Do I want to keep unmatched rows? Then pick the tool that answers those questions.
Now go merge some real data — your analysis will thank you.
Practice recap
Grab two CSV files of your own — or use public datasets — and practice combining them: first with concat() to stack years of data, then with merge() to join customer and order tables. Try both a left and an outer join, and inspect the row counts carefully. You'll build muscle memory for choosing the right tool.
Common mistakes
- Using
concat()to join on a key column instead ofmerge()— you'll end up with duplicated rows or misaligned columns. - Forgetting
ignore_index=Truewhen concatenating rows, resulting in duplicate index labels that break later operations. - Not checking for duplicate keys before a
merge(), causing an unintended Cartesian product and row count explosion. - Trying to merge on columns with different names or data types (e.g., int vs string) without specifying
left_on,right_on, or casting types.
Variations
- Use
pd.mergewithleft_onandright_onwhen key column names differ between DataFrames. - Use
concat(axis=1)to combine DataFrames side-by-side when you need positional column stacking (less common than merging). - Consider
validateparameters (one_to_one,one_to_many, etc.) inpd.mergeto catch unexpected duplicate pairs.
Real-world use cases
- Combining monthly sales reports from separate CSV files into one dataset for trend analysis.
- Enriching order records with customer demographics by merging on
customer_idto uncover segment-level purchasing patterns. - Building a feature matrix for machine learning by concatenating transaction history and customer profile tables on a shared key.
Key takeaways
concat()stacks DataFrames by position;merge()joins them by a shared key;join()is a variant of merge on the index.- Choose join types (
inner,left,right,outer) based on which rows you need to retain. - Always verify the shape of your DataFrame after a merge to catch duplicate key explosions early.
- Use
left_onandright_onwhen key column names differ, and ensure consistent data types before merging. - Mastering these operations is essential for combining multiple data sources into a single analysis-ready dataset.