Type something to search...
Pandas DataFrames: Filtering, Grouping, and Merging Data

Pandas DataFrames: Filtering, Grouping, and Merging Data

Most real data analysis in pandas comes down to three moves. You filter a table down to the rows you care about, you group it to compute totals, averages and counts per category, and you merge it with other tables to bring in related information. Get comfortable with those three and you can answer the large majority of everyday questions: revenue per product last quarter, customers who haven't ordered, how each region did against target.

This guide works through all three on a small, realistic dataset, with the real output at every step so you can see exactly what each operation returns. It also covers the gotchas that trip people up: operator precedence in filters, missing values silently dropping out of groups, and merges that quietly multiply rows.

The examples were run with pandas 3.0 on Python 3.13. If you're brand new to pandas, how to use Python for data analysis is a gentler starting point.

The Sample Data

Two tables: orders, and the customers who placed them.

# data.py
import pandas as pd

pd.set_option("display.width", 120)
pd.set_option("display.max_columns", None)

orders = pd.DataFrame(
    {
        "order_id": [101, 102, 103, 104, 105, 106, 107, 108],
        "customer_id": [1, 2, 1, 3, 4, 2, 5, 1],
        "region": ["North", "South", "North", "East", "West", "South", "East", None],
        "product": ["Mug", "Lamp", "Pen", "Mug", "Lamp", "Mug", "Pen", "Lamp"],
        "quantity": [2, 1, 10, 4, 1, 3, 25, 2],
        "unit_price": [12.0, 65.0, 1.5, 12.0, 65.0, 12.0, 1.5, 60.0],
        "order_date": pd.to_datetime(
            ["2026-01-05", "2026-01-17", "2026-02-02", "2026-02-14",
             "2026-02-20", "2026-03-03", "2026-03-09", "2026-03-21"]
        ),
    }
)
orders["revenue"] = orders["quantity"] * orders["unit_price"]

customers = pd.DataFrame(
    {
        "customer_id": [1, 2, 3, 4, 6],
        "name": ["Ada", "Grace", "Linus", "Margaret", "Guido"],
        "segment": ["Retail", "Business", "Retail", "Business", "Retail"],
    }
)

The two set_option calls stop pandas from truncating columns with ... when printing. Note that the data is deliberately a little messy, as real data is: order 108 has no region, customer 5 placed an order but isn't in the customers table, and customer 6 (Guido) exists but never ordered.

print(orders)
   order_id  customer_id region product  quantity  unit_price order_date  revenue
0       101            1  North     Mug         2        12.0 2026-01-05     24.0
1       102            2  South    Lamp         1        65.0 2026-01-17     65.0
2       103            1  North     Pen        10         1.5 2026-02-02     15.0
3       104            3   East     Mug         4        12.0 2026-02-14     48.0
4       105            4   West    Lamp         1        65.0 2026-02-20     65.0
5       106            2  South     Mug         3        12.0 2026-03-03     36.0
6       107            5   East     Pen        25         1.5 2026-03-09     37.5
7       108            1    NaN    Lamp         2        60.0 2026-03-21    120.0

In pandas 3, text columns like region and product get the dedicated str dtype by default rather than the old catch-all object dtype; you'll see it in orders.dtypes.

Filtering Rows

Boolean Masks

A comparison on a column doesn't return filtered data. It returns a boolean Series, one True/False per row:

print(orders["quantity"] > 5)
0    False
1    False
2     True
3    False
4    False
5    False
6     True
7    False
Name: quantity, dtype: bool

Passing that Series back into the DataFrame keeps the rows where it's True:

print(orders[orders["quantity"] > 5])
   order_id  customer_id region product  quantity  unit_price order_date  revenue
2       103            1  North     Pen        10         1.5 2026-02-02     15.0
6       107            5   East     Pen        25         1.5 2026-03-09     37.5

Notice the index (2 and 6) is preserved. Filtering doesn't renumber rows, which is useful for tracing results back to the original data. Call .reset_index(drop=True) if you want a fresh 0-based index.

Combining Conditions

Combine masks with & (and), | (or), and ~ (not). Two rules always apply:

  1. Use &, |, ~, not Python's and, or, not. The keywords try to turn a whole Series into a single True or False and raise ValueError: The truth value of a Series is ambiguous.
  2. Wrap each condition in parentheses. & binds more tightly than ==, so without parentheses the expression is evaluated in the wrong order.
mugs = orders[(orders["product"] == "Mug") & (orders["quantity"] >= 3)]
print(mugs[["order_id", "product", "quantity"]])
   order_id product  quantity
3       104     Mug         4
5       106     Mug         3

Handy Filtering Methods

Several Series methods produce masks for common cases:

# Membership in a list
orders[orders["region"].isin(["North", "East"])]

# Inclusive range (works for numbers and dates)
orders[orders["order_date"].between("2026-02-01", "2026-02-28")]

# String tests via the .str accessor
orders[orders["product"].str.startswith("L")]

# Missing values
orders[orders["region"].isna()]

# Negation: everything except mugs
orders[~orders["product"].isin(["Mug"])]

For the date range, the result is orders 103, 104 and 105. Comparing a datetime column against strings like "2026-02-01" works because pandas parses them as timestamps.

Missing values deserve attention. NaN never equals anything, including itself, so orders["region"] == None doesn't find order 108. Always use .isna() and .notna().

.loc: Filter Rows and Pick Columns Together

.loc[rows, columns] takes a row mask and a column list in one step:

print(orders.loc[orders["revenue"] > 50, ["order_id", "product", "revenue"]])
   order_id product  revenue
1       102    Lamp     65.0
4       105    Lamp     65.0
7       108    Lamp    120.0

.loc is also the correct way to modify filtered rows, which matters a lot in pandas 3 (see the Copy-on-Write section below).

.query(): Filters as Strings

For longer conditions, query() is often more readable. Column names are used directly, and @ refers to Python variables:

min_rev = 40
print(orders.query("product == 'Lamp' and revenue > @min_rev")[["order_id", "revenue"]])
   order_id  revenue
1       102     65.0
4       105     65.0
7       108    120.0

Inside query() you can use and, or, and not, because pandas parses the string itself. It's purely a style choice; use whichever reads better.

Grouping and Aggregating

groupby() follows a pattern often called split-apply-combine: split the rows into groups by one or more keys, apply a function to each group, and combine the results into a new table.

One Key, One Aggregate

print(orders.groupby("product")["revenue"].sum())
product
Lamp    250.0
Mug     108.0
Pen      52.5
Name: revenue, dtype: float64

The group keys become the index of the result. Chain .sort_values(ascending=False) to rank them:

print(orders.groupby("product")["quantity"].sum().sort_values(ascending=False))
product
Pen     35
Mug      9
Lamp     4
Name: quantity, dtype: int64

Built-in aggregations include sum, mean, median, min, max, count (non-missing values), size (all rows), nunique, std, first, and last. For a quick count per category, orders["product"].value_counts() is a shortcut.

Several Aggregates at Once

Pass a list to agg():

print(orders.groupby("product")["revenue"].agg(["count", "sum", "mean"]))
         count    sum       mean
product
Lamp         3  250.0  83.333333
Mug          3  108.0  36.000000
Pen          2   52.5  26.250000

Named Aggregation

When you need different aggregations of different columns, named aggregation gives you clean column names in one call. Each keyword is an output column, and its value is a (column, function) pair:

summary = orders.groupby("product").agg(
    orders=("order_id", "count"),
    units=("quantity", "sum"),
    revenue=("revenue", "sum"),
    avg_price=("unit_price", "mean"),
)
print(summary)
         orders  units  revenue  avg_price
product
Lamp          3      4    250.0  63.333333
Mug           3      9    108.0  12.000000
Pen           2     35     52.5   1.500000

This is the form I reach for most. It's explicit and produces a table that's ready to export or plot. If you'd rather keep the key as a regular column, pass as_index=False to groupby() (or call .reset_index() on the result).

Grouping by Several Keys

print(orders.groupby(["region", "product"])["revenue"].sum())
region  product
East    Mug        48.0
        Pen        37.5
North   Mug        24.0
        Pen        15.0
South   Lamp       65.0
        Mug        36.0
West    Lamp       65.0
Name: revenue, dtype: float64

The result has a two-level MultiIndex. To turn the inner level into columns, a cross-tab layout, use unstack():

print(orders.groupby(["region", "product"])["revenue"].sum().unstack(fill_value=0))
product  Lamp   Mug   Pen
region
East      0.0  48.0  37.5
North     0.0  24.0  15.0
South    65.0  36.0   0.0
West     65.0   0.0   0.0

pivot_table() does the same thing in one call, and margins=True adds totals:

print(
    orders.pivot_table(
        index="region", columns="product", values="revenue",
        aggfunc="sum", fill_value=0, margins=True,
    )
)
product   Lamp    Mug   Pen    All
region
East       0.0   48.0  37.5   85.5
North      0.0   24.0  15.0   39.0
South     65.0   36.0   0.0  101.0
West      65.0    0.0   0.0   65.0
All      130.0  108.0  52.5  290.5

Watch Out: Missing Keys Disappear

Look at the grand total in that table: 290.5. Now add up the revenue column: it's 410.5. Order 108, the 120.0 Lamp sale with no region, has vanished.

By default, groupby() and pivot_table() drop rows whose group key is missing. That's one of the most common sources of totals that don't reconcile. Keep them with dropna=False:

print(orders.groupby("region", dropna=False)["revenue"].sum())
region
East      85.5
North     39.0
South    101.0
West      65.0
NaN      120.0
Name: revenue, dtype: float64

Or fill the gap first, which often reads better in a report: orders["region"].fillna("Unknown").

transform: Group Results on Every Row

agg() returns one row per group. Sometimes you want the group statistic attached to each original row, for example each order's share of its product's total revenue. That's transform(), which returns a result the same length as the input:

product_total = orders.groupby("product")["revenue"].transform("sum")
orders["share_of_product"] = orders["revenue"] / product_total
print(orders[["order_id", "product", "revenue", "share_of_product"]].round(2))
   order_id product  revenue  share_of_product
0       101     Mug     24.0              0.22
1       102    Lamp     65.0              0.26
2       103     Pen     15.0              0.29
3       104     Mug     48.0              0.44
4       105    Lamp     65.0              0.26
5       106     Mug     36.0              0.33
6       107     Pen     37.5              0.71
7       108    Lamp    120.0              0.48

transform is also how you'd fill missing values with a group mean, or compute a value's difference from its group average.

filter: Keep Whole Groups

groupby().filter() keeps or drops entire groups based on a condition. Here, only customers with at least two orders:

repeat = orders.groupby("customer_id").filter(lambda g: len(g) >= 2)
print(repeat[["order_id", "customer_id"]])
   order_id  customer_id
0       101            1
1       102            2
2       103            1
5       106            2
7       108            1

Grouping by Time

To group by calendar period, derive a period from the date column, or use resample() on a datetime column:

monthly = orders.groupby(orders["order_date"].dt.to_period("M"))["revenue"].sum()
print(monthly)
order_date
2026-01     89.0
2026-02    128.0
2026-03    193.5
Freq: M, Name: revenue, dtype: float64

orders.resample("MS", on="order_date")["revenue"].sum() gives the same totals labelled by each month's start date, and also includes months with no orders as zero rows, which matters for charts.

Merging DataFrames

merge() joins two DataFrames on one or more key columns, like a SQL JOIN. It's how you bring customer names and segments into the orders table.

Inner Join (the Default)

small = orders[["order_id", "customer_id", "product", "revenue"]]
print(small.merge(customers, on="customer_id"))
   order_id  customer_id product  revenue      name   segment
0       101            1     Mug     24.0       Ada    Retail
1       102            2    Lamp     65.0     Grace  Business
2       103            1     Pen     15.0       Ada    Retail
3       104            3     Mug     48.0     Linus    Retail
4       105            4    Lamp     65.0  Margaret  Business
5       106            2     Mug     36.0     Grace  Business
6       108            1    Lamp    120.0       Ada    Retail

An inner join keeps only keys present in both tables. Order 107 is gone because customer 5 isn't in customers, and Guido doesn't appear because he has no orders. Silently losing order 107 is often not what you want.

Left Join

how="left" keeps every row from the left table and fills in what it can:

print(small.merge(customers, on="customer_id", how="left"))
   order_id  customer_id product  revenue      name   segment
0       101            1     Mug     24.0       Ada    Retail
1       102            2    Lamp     65.0     Grace  Business
2       103            1     Pen     15.0       Ada    Retail
3       104            3     Mug     48.0     Linus    Retail
4       105            4    Lamp     65.0  Margaret  Business
5       106            2     Mug     36.0     Grace  Business
6       107            5     Pen     37.5       NaN       NaN
7       108            1    Lamp    120.0       Ada    Retail

For "enrich this table with details from a lookup table", a left join is almost always what you want. Every order stays, and unmatched ones are visible as NaN.

The four join types:

how=KeepsSQL equivalent
"inner" (default)Keys in both tablesINNER JOIN
"left"All rows of the left tableLEFT JOIN
"right"All rows of the right tableRIGHT JOIN
"outer"All keys from either tableFULL OUTER JOIN

Finding Mismatches with indicator=True

indicator=True adds a _merge column saying where each row came from. Combined with an outer join, it's a quick data-quality check:

result = small.merge(customers, on="customer_id", how="outer", indicator=True)
print(result[["order_id", "customer_id", "name", "_merge"]])
   order_id  customer_id      name      _merge
0     101.0            1       Ada        both
1     103.0            1       Ada        both
2     108.0            1       Ada        both
3     102.0            2     Grace        both
4     106.0            2     Grace        both
5     104.0            3     Linus        both
6     105.0            4  Margaret        both
7     107.0            5       NaN   left_only
8       NaN            6     Guido  right_only

Two things to notice. left_only and right_only rows point straight at the orphans. And order_id turned into floats (101.0): the integer column needed to hold a NaN for Guido's row, and NumPy-backed integers can't. If that matters, convert back afterwards or use the nullable Int64 dtype.

The same trick gives you an anti-join, "customers who never ordered":

ordered = orders[["customer_id"]].drop_duplicates()
m = customers.merge(ordered, on="customer_id", how="left", indicator=True)
print(m[m["_merge"] == "left_only"].drop(columns="_merge"))
   customer_id   name segment
4            6  Guido  Retail

Different Key Names and Overlapping Columns

When the key has different names in each table, use left_on and right_on:

c2 = customers.rename(columns={"customer_id": "id"})
small.merge(c2, left_on="customer_id", right_on="id", how="left")

Both key columns are kept; drop the redundant one afterwards.

When both tables have non-key columns with the same name, pandas adds suffixes (_x, _y by default). Choose meaningful ones:

targets = pd.DataFrame({"product": ["Mug", "Lamp", "Pen"], "revenue": [100.0, 300.0, 50.0]})
actual = orders.groupby("product", as_index=False)["revenue"].sum()

comparison = actual.merge(targets, on="product", suffixes=("_actual", "_target"))
comparison["hit_target"] = comparison["revenue_actual"] >= comparison["revenue_target"]
print(comparison)
  product  revenue_actual  revenue_target  hit_target
0    Lamp           250.0           300.0       False
1     Mug           108.0           100.0        True
2     Pen            52.5            50.0        True

Guard Against Row Explosion with validate

If the lookup table accidentally contains a duplicate key, a merge duplicates every matching row on the other side. Your 8 orders become 10, and every total built on them is now wrong. Nothing warns you.

The validate argument turns your assumption into a check:

dup = pd.concat([customers, customers.iloc[[0]]])  # Ada appears twice

small.merge(dup, on="customer_id", validate="many_to_one")
# MergeError: Merge keys are not unique in right dataset; not a many-to-one merge

Options are "one_to_one", "one_to_many", "many_to_one", and "many_to_many". Use it on any merge where you know the expected relationship; it costs almost nothing and catches a whole class of silent bugs. Comparing len() before and after a left join is a good habit too.

Stacking Tables with concat

merge combines tables side by side by key. To stack tables with the same columns on top of each other (monthly exports, files from several stores), use pd.concat():

jan = orders[orders["order_date"].dt.month == 1][["order_id", "revenue"]]
feb = orders[orders["order_date"].dt.month == 2][["order_id", "revenue"]]
print(pd.concat([jan, feb], ignore_index=True))
   order_id  revenue
0       101     24.0
1       102     65.0
2       103     15.0
3       104     48.0
4       105     65.0

ignore_index=True builds a fresh index instead of keeping duplicate labels from each input.

Copy-on-Write: Modifying Filtered Data Safely

pandas 3 makes Copy-on-Write the only behavior: any DataFrame or Series you get from filtering or selecting behaves as an independent copy. That removes the old, confusing SettingWithCopyWarning, but it also means one common pattern never works:

# Chained assignment: does NOT change orders
orders[orders["quantity"] > 5]["unit_price"] = 1.25

pandas 3 raises a ChainedAssignmentError warning here, and orders is left unchanged, because the first [...] produced a copy and the assignment went to that copy. Do the selection and assignment in a single .loc call instead:

orders.loc[orders["quantity"] > 5, "unit_price"] = 1.25

And if you take a filtered subset and modify it, you're modifying only the subset, which is usually what you meant:

big = orders[orders["quantity"] > 5]
big["flag"] = True   # changes big only; orders is untouched

Putting It Together

Method chaining lets you express a whole analysis as one readable pipeline. Revenue per customer since February, including orders from unknown customers:

report = (
    orders.merge(customers, on="customer_id", how="left", validate="many_to_one")
    .assign(name=lambda d: d["name"].fillna("Unknown"))
    .query("order_date >= '2026-02-01'")
    .groupby(["segment", "name"], dropna=False, as_index=False)
    .agg(orders=("order_id", "count"), revenue=("revenue", "sum"))
    .sort_values("revenue", ascending=False)
)
print(report)
    segment      name  orders  revenue
2    Retail       Ada       2    135.0
1  Business  Margaret       1     65.0
3    Retail     Linus       1     48.0
4       NaN   Unknown       1     37.5
0  Business     Grace       1     36.0

Each step returns a new DataFrame: left-join the lookup (validated), fill missing names, filter by date, group (keeping the missing segment), aggregate with named columns, and sort. assign() with a lambda refers to the DataFrame as it exists at that point in the chain.

All of these operations are vectorized, running in compiled code over whole columns at once, which is why pandas is fast even though you're writing Python. NumPy fundamentals explains what's happening underneath, and if your data outgrows pandas, Polars vs pandas covers the main alternative.

Conclusion

Filtering, grouping and merging cover most of what you'll do with DataFrames. Filter with boolean masks combined with &, | and ~ (in parentheses), or with query(), and use .loc whenever you modify the rows you selected. Group with groupby().agg() and named aggregation, reach for transform() when you need group results on each row, and remember that missing keys are dropped unless you pass dropna=False. Merge with an explicit how, check for orphans with indicator=True, and use validate so duplicate keys can't silently inflate your numbers.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading