loc and iloc

Label or position - and the endpoint rule that differs between them.

Overview

The distinction

pandas gives you two explicit accessors for selecting rows and columns. One works by label. The other works by position. They look similar, they accept similar syntax, and on a fresh frame they return the same rows. That last point is the trap.

The difference only shows up after the index has changed. A filter, a sort, a merge, a set_index call -- any of these can make the index labels diverge from the positional order. At that point, label-based and position-based selection refer to different rows, and code that assumed they were interchangeable breaks.

Using bare brackets on a DataFrame is ambiguous. A string selects a column. A slice selects rows by position. A boolean Series selects rows by mask. Three different meanings for one syntax. The explicit accessors remove that ambiguity. They cost a few extra characters and prevent a category of bug.

This module explains the label-versus-position split, the endpoint rule that differs between the two, and the cases where each one is the right choice. It also covers the SettingWithCopy trap that appears when you chain selection and assignment without using the accessor.

The examples use frames with non-default indexes so the difference is visible. On a RangeIndex frame, every example would work with either accessor, which is exactly why people get confused when the index later changes.

Getting comfortable with these two accessors is worth the effort. They appear in almost every pandas script. Knowing which one you mean, and writing it explicitly, makes the code both correct and readable.

.loc selects by label — the values in the index.

.iloc selects by spot — where the row sits, from 0.

On a freshly created frame the index is 0, 1, 2, .... Label and spot are the same number and the two behave the same way. That coincidence is why the gap goes unnoticed until a thing breaks it. A filter, a sort, a join, a set_index.

After any of those, df.loc[0] and df.iloc[0] can refer to other rows. Or .loc[0] can raise KeyError because no row is labelled 0 any more.

Writing .loc or .iloc by name, rather than bare df[...], makes the choice visible. Bare brackets guess. The guess changes with the arg type: df["age"] is a column, df[0:2] is rows by spot, df[mask] is rows by rule. Three meanings for one syntax is a lot to hold. The clear accessors cost four characters.

Worth knowing

.loc selects by label, .iloc by position. On a default index they agree, which hides the difference until the index changes.
.loc slices are inclusive of the last label; .iloc slices exclude the last position like every other Python slice.
Both take rows first, then columns: df.loc[rows, cols].
Boolean masks belong in .loc. .iloc rejects a labelled boolean Series.
.at and .iat read a single cell and are meaningfully faster than .loc in a loop.
Assign through one .loc call with both selections in it — chained brackets are where the copy warning comes from.

loc and iloc

Label or spot, and the endpoint rule that differs between them.

loc takes labels, iloc takes positions

On a default index they look identical, which is exactly why the difference goes unnoticed.

example_01.pypandas
Output

The endpoint rule is different

loc includes the last label; iloc excludes the last position, like every other Python slice.

example_02.pypandas
Output

Two axes at once

Rows first, then columns - and this is the form to prefer over chained brackets.

example_03.pypandas
Output

Boolean masks go in loc

And a mask can be combined with a column selection in the same call.

example_04.pypandas
Output

at and iat for a single value

Faster, and they say 'exactly one cell' out loud.

example_05.pypandas
Output

Assigning through loc is the safe way

One call, one object - the pattern the next module is entirely about.

example_06.pypandas
Output

The endpoint rule

This is the one that catches all.

df.iloc[1:4] gives spots 1, 2, 3. The end is excluded, just like a list slice.

df.loc["b":"d"] gives labels b, c and d. The end is included.

labels have no natural "one past the end". With spots you can point at index 4 to mean "stop before here". With any labels. Strings, dates, non-contiguous ints. There's no such thing as the label after d, so pandas includes it.

The hands-on result: on a default index, df.loc[1:3] returns three rows and df.iloc[1:3] returns two. Both look correct in alone.

Label slicing also needs a sorted index when the labels aren't unique or not monotonic. Else it raises rather than guessing.

Two axes

Both accessors take rows first, then columns:

df.loc[row_selection, column_selection]

Each part can be a single value, a list, a slice or (for .loc) a boolean mask.

df.loc[:, "age"] is a whole column. df.loc["r2"] is a whole row, returned as a Series whose index is the column names.

This two-axis form is the one to prefer. Not only for brevity. df.loc[mask, "col"] is a single indexing step on a single object. This Is what makes set through it solid. df[mask]["col"] is two steps, the first of which may have made a copy. And that is the subject of the next module.

Boolean masks

Masks go in .loc. df.loc[df["age"] >= 25] selects matching rows. df.loc[df["age"] >= 25, ["name", "city"]] selects matching rows and named columns in one call.

.iloc doesn't accept a labelled boolean Series. It will take a plain list or array of booleans. Not a Series. A Series carries an index and .iloc is defined to skip labels. Accepting one would be ambiguous. The error is deliberate.

If you need to use a mask positionally, .to_numpy() on it strips the index.

at and iat

For a single cell, .at (label) and .iat (spot) are faster than .loc and .iloc. They skip the machinery that handles slices, lists and masks.

The gap is small for one call and large in a loop, as the fifth editor measures.

They also raise if you ask for more than one cell. This Makes them self-documenting. Seeing .at in code tells you the author meant just one value.

That said, a loop over cells is often the wrong shape for the problem. .at makes a loop faster. Dropping the loop makes it not needed.

Assignment

The rule is short: **one .loc call, with both the row and column pick inside it**.

df.loc[df["age"] < 30, "age"] = 30

pandas can see this is a single indexing step on df itself. It writes to df.

df[df["age"] < 30]["age"] = 30

doesn't well work. The first bracket makes a new object. The set may write to that temporary rather than to df. Once this raised SettingWithCopyWarning. In current pandas it may silently do nothing.

Assigning a column for a subset of rows fills the unselected rows with NaN. This Is often what you want and is worth expecting rather than discovering.

A working summary

Use .loc when you mean labels, .iloc when you mean spots. Write one of them rather than bare brackets.

note that .loc slices include the endpoint.

Put row and column pick in the same call.

Use .at/.iat for a single cell if you're in a loop. Consider whether the loop should exist.

And after any filter or sort, be aware that spots and labels have parted company. Which is the source of most of the confusion these two accessors exist to prevent.

What bare brackets do

df[...] guesses from the arg, and the guess changes with the type:

df["age"] — a column, returned as a Series.

df[["age", "city"]] — a few columns, returned as a DataFrame.

df[0:2] — rows by spot.

df["a":"c"] — rows by label.

df[mask] — rows by rule.

Five actions, two axes, one syntax. It reads well for the common case. Selecting a column. And is a genuine source of confusion for all else. The same brackets at times mean rows and at times columns.

.loc and .iloc drop the guessing. That is the arg for using them even where bare brackets would work.

One result worth knowing: on a Series, s[0] is ambiguous when the index is ints. Is 0 a label or a spot? pandas treats it as a label. This Raises if there's no label 0 even when there are plenty of rows. This ambiguity is why the spot-based-only accessor exists.

Callable selection

Both accessors accept a function that receives the object and returns a pick:

df.loc[lambda d: d["age"] > 30]

That looks like extra syntax for the same thing. It earns its place in a chain. Here df may not be the object being selected from:

(df.query("year == 2024")
   .assign(total=lambda d: d["a"] * d["b"])
   .loc[lambda d: d["total"] > 100])

Without the lambda, the final loc would have to link an mid that doesn't have a name.

Selecting columns by type or name pattern

df.select_dtypes(include="number") picks columns by dtype, and exclude= is the inverse. This is how you apply an step to each numeric column without listing them.

df.filter(like="date") selects columns whose name holds a substring. regex= takes a pattern. items= takes an clear list.

Both return a frame, so they compose with all else. They're a lot more solid than a hard-coded column list when the source data gains or loses columns.

Setting values

The rules for set through .loc are worth stating by name. Each has a failure mode.

A scalar broadcasts: df.loc[mask, "col"] = 0 fills every selected row.

A list or array must match the pick length just, and goes in positionally.

A Series aligns on the index. Labels that aren't in the pick are skipped and labels missing from it become NaN.

A new column created for a subset leaves NaN in the unselected rows.

The dtype does not widen to fit: assigning 3.7 into an integer column stores 3. Assigning a string into a numeric column may upcast the whole column to object, which is worse.

Enlargement

.loc can create rows and columns that don't exist:

df.loc["new_row"] = [...] adds a row. df.loc[:, "new_col"] = ... adds a column.

.iloc can't — a spot that doesn't exist is an error. There's no sensible spot to create.

Enlargement is convenient for one addition and is the quadratic anti-pattern in a loop. It's the method behind df.loc[len(df)] = row. This Is the slow way to build a frame.

A summary

Use .loc for labels and .iloc for spots. Write one of them rather than leaning on bare brackets to guess.

note .loc slices include the endpoint and .iloc slices don't.

Put both picks in one call, above all when assigning.

Use .at/.iat for a single cell in a loop. Consider whether the loop is needed.

Use select_dtypes and filter instead of hard-coded column lists when the schema may change.

And after any filter or sort, note that labels and spots have parted company. Which is the confusion these accessors exist to prevent.

Copy or view, briefly

Whether .loc returns a view or a copy is on purpose unspecified. Depends on the inner block layout.

That is the whole reason the copy warning exists. It's why the guidance in this module is phrased as "put both picks in one call" rather than "select and then assign".

Under copy-on-write, picks always behave as copies and the ambiguity disappears. But the one-call rule remains correct, because it's also clearer.

Selecting a single row or column

df.loc["r2"] returns a Series whose index is the column names. That means the row's values are forced into one dtype. A row holding an int and a string comes back as object. The int is now an object.

That is a real hazard when iterating rows or passing a row to a function. It's the same problem iterrows has, for the same reason.

df.loc[["r2"]] — a list rather than a scalar — returns a one-row DataFrame instead, preserving each column's dtype. When the dtypes matter, that is the form to use.

The same gap applies to columns: df["a"] is a Series, df[["a"]] is a one-column frame.

Common errors and what they mean

**KeyError** on .loc — the label doesn't exist. Often because the frame was filtered and the labels changed, or because the key is a string where you passed an int.

**IndexError** on .iloc — the spot is out of range.

"Can't mask with non-boolean array holding NA / NaN values". A .str predicate without na=False.

"The truth value of a Series is ambiguous". and/or where &/| was needed, or a missing pair of parentheses.

"can't reindex on an axis with copy labels". The index has repeats and the step needs uniqueness.

**SettingWithCopyWarning** — two indexing steps on the left of an set.

Each of these has one usual cause. Recognising them saves more time than any amount of general debugging.

A short reference

df["col"] — one column, as a Series.

df[["a", "b"]] — a few columns, as a frame.

df.loc[rows, cols] — by label, endpoint inclusive.

df.iloc[rows, cols] — by spot, endpoint exclusive.

df.at[row, col] / df.iat[i, j] — one cell, fast.

df.loc[mask] — rows by rule.

df.loc[mask, "col"] = value — the only set form worth using.

df.select_dtypes(...) / df.filter(...) — columns by type or name pattern.

Questions people ask about loc and iloc

What is the difference between loc and iloc? loc selects by label and iloc selects by integer position. On a default RangeIndex they look alike until you filter or sort.

Why is the end of a loc slice included but not the end of an iloc slice? loc slices by label, and a label range has no natural stop, so pandas includes the end label. iloc follows Python's usual rule of excluding the stop position.

How do I select rows by condition and columns by name together? Use df.loc[mask, ['a', 'b']], where the first part is a boolean Series and the second is a list of column names.

Why does df['a']['b'] = value sometimes fail to change my data? Chained indexing may operate on a temporary copy. Write df.loc['b', 'a'] = value in one step so pandas sets the value on the original.

When should I use at and iat? Use them to read or write one single cell. They are faster than loc and iloc for that case, but only accept a single row and column.

What happens if I use loc with a label that does not exist? Reading raises a KeyError. Assigning with loc to a new row label adds that row, which can hide typos.

A closing note on loc and iloc

The gap is small to state and causes a disproportionate amount of confusion. On a freshly loaded frame label and spot are the same number.

They part company the first time anything filters, sorts, joins or reindexes. And from then on df.loc[0] and df.iloc[0] may be other rows, or .loc[0] may raise because no row is labelled 0 any more.

Writing .loc or .iloc rather than bare brackets makes the choice clear. This Matters because bare brackets guess differently based on what you hand them. A column for a string, rows for a slice, rows for a mask.

The endpoint rule is the other thing to carry: .loc slices include the last label, .iloc slices don't. On a default index the two forms differ by just one row, and both look correct.

And for set there's only one form worth using. A single .loc with the rows and the column both inside it.

Check yourself

0 of 4

Answer without scrolling back up.

  1. On a default index, how many rows does `df.loc[1:3]` return?

  2. Why does `.iloc` reject a boolean Series?

  3. Which assignment reliably modifies `df`?

  4. When do `.loc[0]` and `.iloc[0]` refer to different rows?

Cheat sheet

loc and iloc

Select rows and columns by label with loc and by position with iloc. See the endpoint rule that differs between them and why it matters.

PANDAS · vizlearn.in/pandas/loc_and_iloc.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.