Index and alignment

pandas · Essentials · 10 min

Automatic alignment prevents mismatched-row bugs but creates surprise NaNs if you don't expect it.

The index is not just decoration. When you combine two Series or DataFrames, pandas aligns them on their labels first, then operates on matching pairs. Labels present in only one operand produce NaN in the result.

a = pd.Series([1, 2], index=["x", "y"])
b = pd.Series([10, 20], index=["y", "z"])
a + b   # x NaN, y 12, z NaN

The same happens when assigning a Series as a column: df["new"] = other_series matches rows by index label, not by position. If the indexes don't line up you get NaNs, or silently wrong rows if they happen to overlap in the wrong way.

This is powerful when it's what you want: subtracting last year's sales from this year's by product just works, whatever the row order. It's surprising when you think positionally, for example after filtering or sorting one side only.

Tools to stay in control:

  • a.add(b, fill_value=0) (also sub, mul, div) treats missing labels as 0 instead of producing NaN.
  • a.align(b, join="inner") returns both, restricted to common labels.
  • reset_index(drop=True) throws away the labels and renumbers 0..n-1, when you really mean "by position".
  • .to_numpy() on the right-hand side assigns by position, if the lengths match.
  • df.reindex(new_labels) conforms to a given set of labels, inserting NaN for new ones.

Indexes can contain duplicate labels, which makes alignment produce every matching combination. Check df.index.is_unique when results suddenly have more rows than expected.

Example

import pandas as pd

this_year = pd.Series({"tea": 120, "coffee": 300, "cocoa": 40})
last_year = pd.Series({"coffee": 250, "tea": 100, "juice": 90})

print(this_year - last_year)
# cocoa      NaN
# coffee    50.0
# juice      NaN
# tea       20.0
print(this_year.sub(last_year, fill_value=0))   # cocoa 40, juice -90, ...

df = pd.DataFrame({"v": [1, 2, 3]}, index=[10, 20, 30])
s = pd.Series([100, 200, 300])          # index 0, 1, 2
df["by_label"] = s                      # no matching labels!
df["by_position"] = s.to_numpy()
print(df)
#     v  by_label  by_position
# 10  1       NaN          100
# 20  2       NaN          200
# 30  3       NaN          300

Remember

  • Binary operations align on index labels before computing
  • Labels missing on one side give NaN; use fill_value= to avoid that
  • Assigning a Series as a column aligns by label, not position
  • Use reset_index(drop=True) or .to_numpy() when you mean position

Watch out for

  • Assigning a filtered or re-sorted Series to a column of another DataFrame and getting NaNs or misplaced rows.

Check yourself

  1. `a` has index [0, 1, 2] and `b` has index [1, 2, 3]. How many NaNs does `a + b` contain (no NaNs in the inputs)?
  2. After `df = df[df.x > 0]`, the index has gaps. What does `df['y'] = pd.Series(values)` do with a default 0..n-1 index?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: pandas.pydata.org/docs/user_guide/dsintro.html#data-alignment-and-arithmetic