[] 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 slicesWatch it happen
Play it through, or step back and forth yourself.
dfpandas 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 columnsThat'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 goTwo 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 rowThis one behaves like an ordinary Python index: zero-based, negatives from the end, end excluded.

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 3The 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.

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.
Practice
Write it yourself. The answer is there when you want it.
Putting the kettle on…
Starting up…
Write it yourself
not gradedKeep the orders over 100 cups and print the index labels that came with them — they are gappy. Print the city at loc[3] next to the city at iloc[3], and say which is which. Then set the index to date and print the shape of a loc slice across two days against an iloc slice of two rows. Finish with one .loc taking the rows over 120 cups and the city and cups columns.
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.
Return the first three rows of orders by position.
Return just the city and cups columns for the rows where cups is over 120 — in a single .loc call.
Return the last row of orders, by position.
