pandas·Lesson 5·13 min·0/4 exercises

[] vs .loc vs .iloc

Three ways in, three different questions — and the one that silently returns the wrong row.

df["col"].loc.iloc.at.iatlabel slicesposition slices

Watch it happen

Play it through, or step back and forth yourself.

df
city
cups
mon
Delhi
120
tue
Mumbai
80
wed
Pune
150
thu
Delhi
95

pandas gives you three bracket forms, and they answer different questions. df[...] is a shortcut with its own rules, .loc works by label, and .iloc works by position.

The idea

This is the most-confused topic in pandas, and it isn't because it's hard — it's because three similar-looking things answer three different questions.

[] selects columns

orders["cups"]              # a Series — one column
orders[["city", "cups"]]    # a DataFrame — a list of columns

That's the odd one. For every other indexable object in Python, brackets pick an element; here they pick a column. It's a shortcut for the common case, and it has two exceptions — a boolean mask filters rows, and a slice takes rows. Those exceptions are exactly why bare brackets get confusing, so use them for columns and masks and nothing else.

.loc works by label

df.loc["tue"]              # the row LABELLED tue
df.loc["tue", "cups"]      # row first, then column
df.loc["mon":"wed"]        # three rows — the end IS included
df.loc[df.cups > 100, "cups"]   # filter and column, in one go

Two things to internalise. Rows come first, columns second. And a .loc slice includes its endpoint — unlike every other slice in Python. That's deliberate: with labels there's no "one past the end" to name, so excluding the end would make label slicing nearly useless.

.iloc works by position

df.iloc[1]        # the second row
df.iloc[1, 1]     # row 1, column 1
df.iloc[0:2]      # two rows — the end is EXCLUDED
df.iloc[-1]       # the last row

This one behaves like an ordinary Python index: zero-based, negatives from the end, end excluded.

Hand-drawn notes showing that square brackets select columns while loc and iloc select rows first, one by label and one by position.

Where it bites

As long as your index is words, mixing them up throws an error and you fix it. The danger is when the labels are integers — which is what filtering leaves behind, since the original 0, 1, 2… labels come along with their rows:

busy = orders[orders["cups"] > 100]
busy.index          # [0, 3, 5, 7, 8, 10] — gappy

busy.loc[3]         # the row LABELLED 3
busy.iloc[3]        # the FOURTH row
busy[3]             # KeyError — no column called 3

The first two both work and return different rows. No exception, no warning. That's the bug that survives code review, and it's why the advice is always to be explicit.

Hand-drawn notes showing that a loc slice includes its final label while an iloc slice excludes its final position, like an ordinary Python slice.

Which to reach for

  • Labels mean something — dates, ids, names — use .loc.
  • You genuinely mean position — "the first five rows" — use .iloc.
  • Columns or a boolean mask — bare [] is fine and reads well.

And for a single cell in a loop, .at and .iat are the fast paths — same label-versus-position split, much less overhead, one cell only.

See it run

The lesson's code, ready to run and to fiddle with.

Putting the kettle on…

Starting up…

Worked example

not graded

Already written and ready to go — press Run to see what it does, then change a number, a column name, anything, and run it again.

trybusy[3] and read the error — bare brackets look for a column.

Press Run — the output appears here.

Your turn

4 exercises. Write the code yourself, then press Check — a nudge and the answer are there if you want them.

Return the city of the row labelled 2 in orders, using .loc.

your answer

Return the first three rows of orders by position.

your answer

Return just the city and cups columns for the rows where cups is over 120 — in a single .loc call.

your answer

Return the last row of orders, by position.

your answer