pandas
DataFrame
python
apply function
data manipulation

Return multiple columns from pandas apply

ML System Design practice on Codemia

Design recommenders, ranking systems and training pipelines the way ML interviews actually ask for them, with worked solutions.

Practice ML system design

Introduction

Returning multiple columns from DataFrame.apply() is a common pattern when one row contains enough information to derive several output fields. The key is to return a shape pandas can expand reliably, usually a named Series or a tuple that is explicitly expanded into separate columns.

Return a Named Series

The clearest pattern is to return a Series with named entries:

python
1import pandas as pd
2
3orders = pd.DataFrame(
4    {
5        "price": [100, 250, 80],
6        "tax_rate": [0.13, 0.13, 0.05],
7        "discount": [0, 25, 0],
8    }
9)
10
11def compute_fields(row: pd.Series) -> pd.Series:
12    subtotal = row["price"] - row["discount"]
13    tax = round(subtotal * row["tax_rate"], 2)
14    total = round(subtotal + tax, 2)
15    return pd.Series({"subtotal": subtotal, "tax": tax, "total": total})
16
17orders[["subtotal", "tax", "total"]] = orders.apply(compute_fields, axis=1)
18print(orders)

This is explicit, readable, and self-documenting.

Tuple Expansion for Simpler Cases

If the logic is simple and you already know the target column order, a tuple can be concise:

python
1def compute_tuple(row: pd.Series):
2    subtotal = row["price"] - row["discount"]
3    tax = round(subtotal * row["tax_rate"], 2)
4    total = round(subtotal + tax, 2)
5    return subtotal, tax, total
6
7expanded = orders.apply(compute_tuple, axis=1, result_type="expand")
8expanded.columns = ["subtotal2", "tax2", "total2"]
9orders = pd.concat([orders, expanded], axis=1)
10print(orders)

This works well, but it is less self-documenting than returning a named Series.

Do Not Forget axis=1

This is one of the easiest mistakes:

python
orders.apply(compute_fields, axis=1)

axis=1 means apply the function row by row. Without it, pandas applies by column instead, and your function may receive an entirely different shape than expected.

Validate Output Shape When the Function Evolves

If the transformation function changes over time, it helps to validate the returned structure:

python
1def validated_compute(row: pd.Series) -> pd.Series:
2    out = compute_fields(row)
3    expected = ["subtotal", "tax", "total"]
4
5    if list(out.index) != expected:
6        raise ValueError(f"Unexpected output columns: {list(out.index)}")
7
8    return out
9
10orders[["subtotal", "tax", "total"]] = orders.apply(validated_compute, axis=1)

This catches accidental schema drift early instead of silently corrupting downstream logic.

Prefer Vectorized Operations When Possible

apply() is flexible, but it is not always the fastest choice. If the logic is simple arithmetic across columns, vectorized expressions are usually better:

python
orders["subtotal_v"] = orders["price"] - orders["discount"]
orders["tax_v"] = (orders["subtotal_v"] * orders["tax_rate"]).round(2)
orders["total_v"] = (orders["subtotal_v"] + orders["tax_v"]).round(2)

Use row-wise apply() when:

  • the logic is genuinely row-dependent
  • the transformation is awkward to express vectorially
  • clarity matters more than raw speed for the specific workload

Assigning the Result Cleanly

If you know the output columns ahead of time, assign them explicitly:

python
orders[["subtotal", "tax", "total"]] = orders.apply(compute_fields, axis=1)

This is often cleaner than creating intermediate unnamed structures and trying to merge them later.

Keep Transformation Functions Small

When the row function gets too large, it becomes harder to reason about both the logic and the return shape. Small transformation functions with clear output fields are easier to test and safer to reuse.

If the logic becomes large enough to feel like a mini-parser, that is usually a signal to move it out into a more explicit transformation layer rather than hiding everything inside one apply() call.

Common Pitfalls

  • Forgetting axis=1 and accidentally applying by column.
  • Returning plain lists with inconsistent lengths.
  • Using row-wise apply() for logic that would be cleaner and faster as vectorized expressions.
  • Assigning expanded results without stable column names.
  • Letting the output shape drift over time without validation.

Summary

  • Return a named Series when you want the clearest multi-column result.
  • Use tuple expansion for concise simple cases.
  • Always remember axis=1 for row-wise logic.
  • Prefer vectorized expressions when the calculation is straightforward.
  • Keep the output shape explicit so downstream code stays predictable.

Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Basis Lab
No course covers this one yet

Basis Lab writes one for you from a sentence about what you want to be able to do, then teaches it and checks you understood. Your first course is free.

Build my course
ML System Design practice on Codemia

Design recommenders, ranking systems and training pipelines the way ML interviews actually ask for them, with worked solutions.

Practice ML system design