kmail.at
← learning

pandas · difficulty ◆

pandas loc[] — label-based selection and honest updates

Select by label, slice with both endpoints included, and write back the way pandas actually endorses.

You have typed df.loc[...] a thousand times without knowing it was born to kill pandas' most infamous guessing game.

2026-08-29 · 4 min read

$ df.loc[]

What it does

df.loc[] is the label-based indexer: give it row labels and column names and it hands back exactly those rows and columns. It accepts a single label, a list of labels, a label slice — note that df.loc['b':'d'] includes the stop label, unlike every other Python slice — a boolean mask aligned to the index, or a callable. The comma splits axes: df.loc[rows, cols]. Combine mask and column list — df.loc[df.region == 'EU', 'units'] — and you get filtered selection in one expression, which is also the form the pandas team endorses for writing values back.

Why it matters

Selection is the most frequent thing you do in pandas, and .loc is the spelling that never surprises you: a missing label raises KeyError instead of guessing, and the slice endpoint is right there in your code. Its second job is quieter but bigger: under the Copy-on-Write default in pandas 3.0, chained assignment like df[mask]['col'] = x writes to a temporary copy that vanishes — I ran it on 3.0.3 and the frame did not change. df.loc[mask, 'col'] = x is the one assignment form that works and will keep working.

Example

$ df.loc['Tokyo']
units       210
revenue    3990
region     APAC
Name: Tokyo, dtype: object

One label returns that row as a Series; columns become the index.

$ df.loc[['Berlin', 'Tokyo'], ['units', 'revenue']]
        units  revenue
Berlin    120     2400
Tokyo     210     3990

Label lists pick rows and columns in the exact order you pass them.

$ df.loc['Paris':'Vienna']
        units  revenue region
Paris      85     1530     EU
Tokyo     210     3990   APAC
Vienna     95     1615     EU

Label slices INCLUDE the stop label — unlike every other Python slice.

$ df2.loc[df2.units < 100, 'region'] = 'EU-small'
df2
        units  revenue    region
Berlin    120     2400        EU
Paris      85     1530  EU-small
Tokyo     210     3990      APAC
Vienna     95     1615  EU-small

Mask plus column is the endorsed pattern for conditional updates.

$ df.loc[df.region == 'EU', 'units'].sum()
np.int64(300)

Filter then aggregate in one chain — the daily-driver pattern.

$ df.loc['Prague']
KeyError: 'Prague'

Missing labels raise — .loc never guesses, which is exactly why it is safe.

$ df.loc[lambda d: d.units > 100]
        units  revenue region
Berlin    120     2400     EU
Tokyo     210     3990   APAC

A callable indexer keeps selection inside method chains.

$ df.loc['Tokyo', 'revenue']
np.int64(3990)

Two scalars give a scalar back — but df.at[] does it with less machinery.

Common flags

df.loc['Tokyo']
single label returns one row as a Series
df.loc[['a', 'b'], ['x', 'y']]
label lists for rows and columns, order preserved
df.loc['a':'c']
label slice, stop label INCLUDED — and .loc[-1] is still a KeyError
df.loc[df.units > 100]
boolean mask, the filtering workhorse
df.loc[mask, 'col'] = v
the endorsed write-back form; survives Copy-on-Write
df.loc['new'] = row
assigning an unseen label appends an enlargement row
df.loc[lambda d: ...]
callable indexer — selection inside a method chain

History

Formalized in pandas 0.11 (April 22, 2013)

The 0.11.0 release notes introduced .loc and .iloc as the two explicit indexers — '.loc is strictly label based, will raise KeyError when the items are not found' — and in the same breath listed irow, icol, and iget_value as headed for deprecation. .loc was the designated successor to the .ix guessing game from day one.

The .ix era ends (0.20 in 2017, removal in 1.0)

.ix tried to guess labels versus positions and was deprecated in 0.20.0 (May 2017). Its removal in 1.0.0 (January 2020) left .loc and .iloc as a deliberate pair: label-based versus position-based, no guessing. Copy-on-Write (default since 2.0, the only behavior in 3.0) then made df.loc[mask, 'col'] = x the single sanctioned write-back form.

Fun facts

Pros & cons

pros

  • + Handles labels, lists, slices, masks, and callables with one syntax
  • + The only write-back form that survives Copy-on-Write without warnings
  • + Raises KeyError on missing labels — explicit beats clever

cons

  • − Label slices including the stop label surprise people coming from Python lists
  • − Scalar .loc access is slower than .at — extra machinery you rarely need for one cell
  • − One indexer with many input types means many subtle rules to internalize

Takeaways

  1. 1df.loc[rows, cols] — always rows first, columns second; missing labels raise KeyError, which is a feature
  2. 2Label slices include the stop label: df.loc['a':'c'] gives you 'a', 'b', AND 'c'
  3. 3Write back with df.loc[mask, 'col'] = x — chained df[mask]['col'] = x silently fails under Copy-on-Write
  4. 4Scalar single lookups deserve df.at['row', 'col'] — .loc works but does more work than needed
  5. 5df.loc[len(df)] = row appends to default-indexed frames — fine for scripts, prefer pd.concat in modern code

Related commands