pandas loc iloc are the two indexers you'll reach for constantly in real pandas code — and they look deceptively similar while following genuinely different rules. This article covers pandas data selection filtering in depth, with careful attention to the inclusive-versus-exclusive slicing distinction and the integer-index ambiguity that together cause more subtle real-world bugs than almost anything else in everyday pandas work.
Two Indexers, Two Different Rules
Two indexers that look similar but aren't
Pandas gives you several ways to select data from a DataFrame, but two indexers appear constantly in real-world code: .loc and .iloc. They look similar at a glance — both use square-bracket syntax, both can select rows and columns — but they follow fundamentally different rules.
The core distinction to lead with
.loc treats its input as row or column label names. .iloc treats its input as numeric row or column positions, 0-based, exactly like ordinary Python list indexing. This single distinction is the foundation for everything else covered in this article — and misunderstanding it is precisely what causes the subtle, silent bugs covered in Section 4.
What this article covers
Label-based selection with .loc, position-based selection with .iloc, the integer-index ambiguity that makes the distinction genuinely dangerous to overlook, boolean filtering with both indexers, and managing a DataFrame's index directly with .set_index()/.reset_index().
Label-Based Selection with .loc
Basic usage
import pandas as pd
df = pd.DataFrame({
"name": ["Alex", "Sam", "Jordan"],
"salary": [65000, 58000, 72000],
}, index=["emp001", "emp002", "emp003"])
print(df.loc[:, "salary"])
# emp001 65000
# emp002 58000
# emp003 72000
# Name: salary, dtype: int64
print(df.loc[["emp001", "emp002"]])
# name salary
# emp001 Alex 65000
# emp002 Sam 58000df.loc[:, "salary"] selects all rows (:) for a specific column, by name. df.loc[["emp001", "emp002"]] selects specific rows by their label — here, the custom string index values assigned when the DataFrame was created, rather than any numeric position.
The slicing behavior that differs from ordinary Python
This is genuinely the most important, most commonly misunderstood detail about .loc, so it's worth stating with complete precision: .loc slicing includes both the start and end labels — unlike ordinary Python list or string slicing, which always excludes the endpoint.
df2 = pd.DataFrame({"value": [10, 20, 30, 40, 50]}, index=["a", "b", "c", "d", "e"])
print(df2.loc["a":"c"])
# value
# a 10
# b 20
# c 30df.loc["a":"c"] returns rows labeled "a" through "c" inclusive — all three rows, a, b, and c. Compare this directly to a plain Python list slice, some_list[0:2], which would give you indices 0 and 1 only, excluding index 2 entirely. This inclusive behavior is a deliberate, documented design choice in pandas — but it genuinely surprises people coming from ordinary Python slicing conventions, precisely because the syntax looks so similar while behaving differently.
Conditional filtering with .loc
high_earners = df.loc[df["salary"] > 60000]
print(high_earners)
# name salary
# emp001 Alex 65000
# emp003 Jordan 72000df["salary"] > 60000 produces a boolean mask (True/False per row, exactly mirroring the boolean indexing covered in the earlier NumPy indexing article), and .loc[] uses that mask to filter down to only the matching rows.
Reading and writing through the same indexer
df.loc[df["salary"] > 60000, "salary"] = df.loc[df["salary"] > 60000, "salary"] * 1.1
print(df)
# name salary
# emp001 Alex 71500.0
# emp002 Sam 58000.0
# emp003 Jordan 79200.0.loc works identically for both reading and writing — here, giving every high earner a 10% raise, updating the DataFrame directly, in place. This combined read/write capability through the same indexer is a genuinely common, practical pattern for conditionally updating specific values.
Position-Based Selection with .iloc
Basic usage
df3 = pd.DataFrame({
"name": ["Alex", "Sam", "Jordan", "Casey", "Morgan"],
"salary": [65000, 58000, 72000, 61000, 69000],
})
print(df3.iloc[1])
# name Sam
# salary 58000
# Name: 1, dtype: object
print(df3.iloc[1:4])
# name salary
# 1 Sam 58000
# 2 Jordan 72000
# 3 Casey 61000
print(df3.iloc[[2, 4], [0]])
# name
# 2 Jordan
# 4 Morgandf3.iloc[1] selects the row at position 1 (the second row, since positions are 0-based) — regardless of what its actual index label happens to be. df3.iloc[1:4] selects a positional slice. df3.iloc[[2, 4], [0]] combines specific, non-contiguous row positions with a specific column position, all in one call.
The critical slicing difference to hold onto
This is the direct counterpart to the inclusive behavior covered for .loc in Section 2, and it's worth stating with exactly the same precision: .iloc slicing excludes the stop position, exactly like standard Python list and string slicing.
print(df3.iloc[1:4])
# name salary
# 1 Sam 58000
# 2 Jordan 72000
# 3 Casey 61000df3.iloc[1:4] returns positions 1, 2, and 3 — not position 4 — exactly matching how some_list[1:4] would behave on a plain Python list. This is the precise opposite of .loc's inclusive slicing behavior covered above, and confusing the two is a genuine, common source of off-by-one bugs: code that works correctly with .loc["a":"c"] (three inclusive labels) can silently return one fewer row than expected if mistakenly rewritten using .iloc with the equivalent-looking numeric bounds.
When .iloc is the right choice
Reach for .iloc when you're working with positional views — "give me the first five rows," "give me every row except the last" — genuinely positional computations, or datasets that don't have (or don't need) meaningful, human-readable labels. In these situations, "the 3rd row" is a more natural way to think about the data than "the row labeled X."
The Integer-Index Ambiguity and Boolean Filtering
A crucial warning worth its own section
Here's a genuinely dangerous scenario worth understanding precisely: when a DataFrame has integer index labels — which happens by default, unless you explicitly set a different index — .loc and .iloc can silently return different rows for what looks like the same call:
df4 = pd.DataFrame({"value": [100, 200, 300]}, index=[5, 3, 8])
print(df4.loc[0])
# KeyError: 0 — there's no row LABELED 0 in this DataFrame's index
print(df4.iloc[0])
# value 100
# Name: 5, dtype: int64 — the FIRST row by position, which happens to be labeled 5df4.loc[0] looks for a row labeled 0 — and since this DataFrame's actual index values are [5, 3, 8], there is no row labeled 0 at all, raising a KeyError. df4.iloc[0] looks for the row at position 0 — the first row, regardless of its label — which in this case happens to be the row labeled 5.
This ambiguity is precisely why it's genuinely important to prefer explicit .loc/.iloc over plain bracket indexing (df[0], or worse, df[something_that_looks_numeric]) for anything beyond basic column selection — plain bracket notation's behavior can depend on exactly this kind of index confusion in ways that aren't always immediately obvious, especially on a DataFrame whose index has been reordered, filtered, or otherwise doesn't run cleanly from 0 upward.
Boolean filtering with both indexers
mask = df4["value"] > 150
print(df4.loc[mask])
# value
# 3 200
# 8 300
print(df4.iloc[mask.values])
# value
# 3 200
# 8 300For a boolean mask, .loc aligns it by index label, while .iloc (which technically requires a plain array or list of Booleans, hence .values above) aligns it by position. For a straightforward, default sequential index, these two behave functionally identically — but the distinction genuinely matters once you're working with a DataFrame that's been reindexed, filtered, or otherwise no longer has a clean, default 0, 1, 2, ... sequence, exactly the scenario demonstrated with df4 above.
Combining multiple conditions correctly
df5 = pd.DataFrame({
"department": ["Sales", "Engineering", "Sales", "Engineering"],
"salary": [55000, 72000, 61000, 68000],
})
result = df5.loc[(df5["department"] == "Sales") & (df5["salary"] > 58000)]
print(result)
# department salary
# 2 Sales 61000A common beginner trap: combining conditions with Python's own and/or keywords doesn't work correctly here, and raises an error:
# WRONG — raises: ValueError: The truth value of a Series is ambiguous
result = df5.loc[(df5["department"] == "Sales") and (df5["salary"] > 58000)]Python's and/or are designed to work on single Boolean values, not entire arrays of them — pandas raises an error rather than silently guessing what you meant. The correct operators are & (and) and | (or), the bitwise operators covered in the earlier bitwise operators article, which pandas has specifically overloaded to work element-wise across boolean Series. One more detail worth remembering: each individual condition needs its own parentheses, exactly as shown above — without them, Python's own operator precedence would try to evaluate &/| before the comparisons themselves, producing an error or an incorrect result.
Managing the Index: set_index, reset_index, and Practical Guidance
Setting a more meaningful index
df = pd.DataFrame({
"employee_id": ["emp001", "emp002", "emp003"],
"name": ["Alex", "Sam", "Jordan"],
"salary": [65000, 58000, 72000],
})
df = df.set_index("employee_id")
print(df)
# name salary
# employee_id
# emp001 Alex 65000
# emp002 Sam 58000
# emp003 Jordan 72000.set_index("employee_id") turns a plain, default sequential integer index into something genuinely label-based and readable — an employee ID, in this case — making subsequent .loc selections read far more naturally: df.loc["emp001"] is considerably more meaningful than df.loc[0], and it sidesteps the integer-index ambiguity from Section 4 entirely, since the index is no longer made of plain, easily-confused integers at all.
Reverting with reset_index
df = df.reset_index()
print(df)
# employee_id name salary
# 0 emp001 Alex 65000
# 1 emp002 Sam 58000
# 2 emp003 Jordan 72000.reset_index() reverts back to a default, plain integer index, moving whatever the previous index was back into an ordinary column. This is genuinely useful when a meaningful index is no longer needed for the current step, or — importantly — before merging or concatenating with another DataFrame that expects a standard, default index rather than a custom one, a topic covered in more depth in a later article in this series.
Closing decision guidance
Use .loc when labels are genuinely meaningful, and code readability is the priority — which describes the majority of real-world analysis work, where you're dealing with named columns and meaningfully identified rows (dates, IDs, categories). Reach for .iloc specifically when you're working with purely positional data — "give me the first N rows," numerical computations indifferent to labels, or datasets without meaningful row identifiers at all.
Performance differences between .loc and .iloc are genuinely negligible for small-to-medium datasets — this isn't a decision that should be driven by micro-optimization. Clarity and correctness should generally win: choose whichever indexer expresses your actual intent most directly, and — as covered throughout this article — be deliberate and explicit about which one you're using, rather than relying on plain bracket indexing's more ambiguous, context-dependent behavior.