The complete pandas loc iloc reference: loc for label-based selection, iloc for position-based selection, boolean indexing, the query method, and isin, each one with code you can run and the exact output it prints.
“Data is the new oil, but only if you can extract it.”
Clive Humby
Last Updated: July 2026 | Tested on: Python 3.14.6, Pandas 2.3.3 | Difficulty: Advanced | Reading Time: 14 minutes
Pandas gives you two ways to grab rows and columns, and they look almost identical until one of them quietly returns the wrong data. loc selects by label (the row and column names you can see). iloc selects by integer position (0, 1, 2, counting from the top). One easy way to remember it: loc starts with L for Label, iloc starts with I for Integer.
Think of a train. loc is asking for the passenger named “Rahul”. iloc is asking for whoever is sitting in seat 3. Sort the train by ticket price and Rahul keeps his name, but seat 3 now holds a completely different person. That is the whole story of this post in one sentence.
Say a data engineer named Viraj learned this the hard way. He spent an hour chasing a bug in a data pipeline because he reached for iloc when he meant loc. After his code sorted the DataFrame, the integer positions no longer lined up with the labels, so the row at position 0 was no longer the row labelled 0. Once the difference clicks, that entire class of bug just disappears.
The diagram lines up the pandas loc iloc pair side by side. .loc[] works with labels (the row and column names), while .iloc[] works with integer positions (0-based, like a Python list). Both accept a single value, a slice, or a list. The trap is in the slice: .loc includes the last item, .iloc stops one short of it. That single difference causes more pandas bugs than anything else, so keep this picture handy whenever you are not sure which selector to reach for.
Table of Contents
Prerequisites
Work through the Pandas introduction first. You should already be comfortable building a DataFrame and grabbing a column with df["age"]. Everything below builds straight on that.
Pandas loc iloc Cheat Sheet
| Operation | loc (Label) | iloc (Integer) |
|---|---|---|
| Single row | df.loc["Rahul"] | df.iloc[0] |
| Multiple rows | df.loc[["Rahul","Viraj"]] | df.iloc[[0,2]] |
| Row slice | df.loc["A":"C"] (inclusive) | df.iloc[0:3] (exclusive) |
| Single column | df.loc[:, "age"] | df.iloc[:, 1] |
| Row + Column | df.loc["Rahul", "age"] | df.iloc[0, 1] |
| Boolean filter | df.loc[df["age"]>25] | (iloc has no boolean form, use loc) |
loc: Label-Based Selection
Think of calling a friend from your phone contacts: you tap the name “Aditi”, not “the third person in my list”. That is exactly how loc works. Everything in this block uses names: row labels and column labels. Notice the index is a set of names (Rahul, Niranjan, and so on), not the usual 0, 1, 2, which is where loc really earns its keep.
📄 loc_examples.py: select by labels
import pandas as pd
df = pd.DataFrame({
"age": [28, 31, 25, 29, 27],
"city": ["Mumbai", "Pune", "Mumbai", "Delhi", "Pune"],
"salary": [75000, 82000, 71000, 65000, 78000]
}, index=["Rahul", "Niranjan", "Viraj", "Pravin", "Sardar"])
print(f"DataFrame:\n{df}\n")
# Single row by label
print(f"Rahul's data:\n{df.loc['Rahul']}\n")
# Multiple rows
print(f"Rahul & Viraj:\n{df.loc[['Rahul', 'Viraj']]}\n")
# Row slice (INCLUSIVE on both ends!)
print(f"Niranjan to Pravin:\n{df.loc['Niranjan':'Pravin']}\n")
# Row + Column
print(f"Viraj's salary: {df.loc['Viraj', 'salary']}")
# Multiple columns
print(f"\nAge & salary columns:\n{df.loc[:, ['age', 'salary']]}")
# Boolean with loc
mumbai = df.loc[df["city"] == "Mumbai"]
print(f"\nMumbai residents:\n{mumbai}")
▶ Output
DataFrame:
age city salary
Rahul 28 Mumbai 75000
Niranjan 31 Pune 82000
Viraj 25 Mumbai 71000
Pravin 29 Delhi 65000
Sardar 27 Pune 78000
Rahul's data:
age 28
city Mumbai
salary 75000
Name: Rahul, dtype: object
Rahul & Viraj:
age city salary
Rahul 28 Mumbai 75000
Viraj 25 Mumbai 71000
Niranjan to Pravin:
age city salary
Niranjan 31 Pune 82000
Viraj 25 Mumbai 71000
Pravin 29 Delhi 65000
Viraj's salary: 71000
Age & salary columns:
age salary
Rahul 28 75000
Niranjan 31 82000
Viraj 25 71000
Pravin 29 65000
Sardar 27 78000
Mumbai residents:
age city salary
Rahul 28 Mumbai 75000
Viraj 25 Mumbai 71000
What happened here: Every selection used a name, never a number. The slice df.loc['Niranjan':'Pravin'] is the one to watch: it returned Niranjan, Viraj, and Pravin, so the row labelled Pravin came back too. That is the inclusive behaviour. A single row like df.loc['Rahul'] returns a Series (one column flipped on its side), while a list of labels like df.loc[['Rahul', 'Viraj']] returns a DataFrame. Pass a boolean mask to loc and you get filtering for free, which is how most real-world pandas selection actually happens.
iloc: Integer Position-Based Selection
Same DataFrame, but now we ignore the names completely and count positions from the top: 0, 1, 2, 3, 4. If you have ever indexed a Python list, this will feel like home.
📄 iloc_examples.py: select by position numbers
import pandas as pd
df = pd.DataFrame({
"age": [28, 31, 25, 29, 27],
"city": ["Mumbai", "Pune", "Mumbai", "Delhi", "Pune"],
"salary": [75000, 82000, 71000, 65000, 78000]
}, index=["Rahul", "Niranjan", "Viraj", "Pravin", "Sardar"])
# Single row by position
print(f"Row 0:\n{df.iloc[0]}\n")
# Slice (EXCLUSIVE end, like Python)
print(f"Rows 0-2:\n{df.iloc[0:3]}\n")
# Specific positions
print(f"Rows 0 & 3:\n{df.iloc[[0, 3]]}\n")
# Row + Column by position
print(f"Row 1, Col 2: {df.iloc[1, 2]}")
# Last N rows
print(f"\nLast 2 rows:\n{df.iloc[-2:]}")
# Every other row
print(f"\nEvery other row:\n{df.iloc[::2]}")
▶ Output
Row 0:
age 28
city Mumbai
salary 75000
Name: Rahul, dtype: object
Rows 0-2:
age city salary
Rahul 28 Mumbai 75000
Niranjan 31 Pune 82000
Viraj 25 Mumbai 71000
Rows 0 & 3:
age city salary
Rahul 28 Mumbai 75000
Pravin 29 Delhi 65000
Row 1, Col 2: 82000
Last 2 rows:
age city salary
Pravin 29 Delhi 65000
Sardar 27 Pune 78000
Every other row:
age city salary
Rahul 28 Mumbai 75000
Viraj 25 Mumbai 71000
Sardar 27 Pune 78000
What happened here: Same DataFrame, but now we count positions instead of reading names. df.iloc[0:3] stopped at position 2 and left position 3 out, which is exactly how a plain Python list slice behaves. That is the big contrast with loc: loc includes the endpoint, iloc excludes it. Negative positions also work, so df.iloc[-2:] grabs the last two rows and df.iloc[::2] takes every second row. Reach for iloc when you care about “the first five” or “the last row” and the actual labels do not matter.
Boolean Indexing & query()
This is where most real selection happens. Instead of naming rows, you describe a condition (“salary above 72000”) and pandas hands back every row that matches. Think of it like the search box in your email: you type the rule, the matching items appear. Once you can filter rows confidently, the natural next step is summarising them with groupby and pivot tables.
📄 boolean_query.py: complex filtering made readable
import pandas as pd
df = pd.DataFrame({
"name": ["Rahul", "Niranjan", "Viraj", "Pravin", "Sardar", "Prathamesh"],
"age": [28, 31, 25, 29, 27, 33],
"dept": ["Eng", "DS", "Eng", "Mkt", "DS", "Eng"],
"salary": [75000, 82000, 71000, 65000, 78000, 91000]
})
# Boolean indexing with multiple conditions
senior_eng = df[(df["dept"] == "Eng") & (df["salary"] > 72000)]
print(f"Senior Engineers (salary > 72K):\n{senior_eng}\n")
# query() method: cleaner for complex conditions
result = df.query("dept == 'DS' or salary > 80000")
print(f"DS dept or salary > 80K:\n{result}\n")
# query with variables
min_salary = 75000
result2 = df.query("salary >= @min_salary")
print(f"Salary >= {min_salary}:\n{result2}\n")
# isin() for multiple values
target_depts = ["Eng", "DS"]
result3 = df[df["dept"].isin(target_depts)]
print(f"Engineering or Data Science:\n{result3}")
▶ Output
Senior Engineers (salary > 72K):
name age dept salary
0 Rahul 28 Eng 75000
5 Prathamesh 33 Eng 91000
DS dept or salary > 80K:
name age dept salary
1 Niranjan 31 DS 82000
4 Sardar 27 DS 78000
5 Prathamesh 33 Eng 91000
Salary >= 75000:
name age dept salary
0 Rahul 28 Eng 75000
1 Niranjan 31 DS 82000
4 Sardar 27 DS 78000
5 Prathamesh 33 Eng 91000
Engineering or Data Science:
name age dept salary
0 Rahul 28 Eng 75000
1 Niranjan 31 DS 82000
2 Viraj 25 Eng 71000
4 Sardar 27 DS 78000
5 Prathamesh 33 Eng 91000
What happened here: All four filters return rows, just expressed in different styles. The bracket form df[(df["dept"] == "Eng") & (df["salary"] > 72000)] needs parentheses around each condition and the bitwise & (not the word and), because pandas compares whole columns at once. The query() form reads like a sentence and lets you pull in a Python variable with the @ prefix, so @min_salary means “the value of min_salary“. And isin() is the clean way to say “match any of these values” instead of chaining a pile of == checks with or.
df.query("dept == 'Eng' and salary > 72000") when a filter has more than two conditions. It drops the repeated df[...] noise and the easy-to-forget parentheses, so the line reads the way you would say it out loud. Keep the bracket form for one quick condition where a string expression would be overkill.Common Mistakes
Mistake 1: Expecting loc and iloc to slice the same way
Here comes the pandas loc iloc difference that bites everyone at least once. A loc slice keeps the last label, a iloc slice drops it.
📄 slice_difference.py: count the rows each slice returns
import pandas as pd
df = pd.DataFrame({"val": [10, 20, 30]}, index=["A", "B", "C"])
# loc slices are INCLUSIVE: "A" through "C" returns all three rows
print("df.loc['A':'C'] gives", len(df.loc["A":"C"]), "rows")
# iloc slices are EXCLUSIVE: positions 0 and 1, position 2 is left out
print("df.iloc[0:2] gives", len(df.iloc[0:2]), "rows")
▶ Output
df.loc['A':'C'] gives 3 rows df.iloc[0:2] gives 2 rows
Why: loc thinks in labels, and “A to C” naturally means A, B, and C. iloc follows Python’s own slicing rule, where 0:2 means positions 0 and 1, stopping before 2. If you want the first three rows by position, write iloc[0:3], not iloc[0:2].
Mistake 2: Trusting iloc positions after a sort
Remember the bug Viraj hit at the start? This is it. Sorting reorders the rows, so position 0 points at a new row, but the labels stay glued to their data.
📄 sort_then_select.py: labels survive a sort, positions do not
import pandas as pd
df = pd.DataFrame(
{"score": [90, 50, 70]},
index=["Rahul", "Niranjan", "Viraj"],
)
print("Before sorting, position 0 is Rahul:")
print("df.iloc[0] score ->", df.iloc[0]["score"])
# Sort by score (lowest first). Labels stick to rows, positions do not.
df = df.sort_values("score")
print(df)
print("df.loc['Rahul'] ->", df.loc["Rahul", "score"]) # still Rahul, label follows the row
print("df.iloc[0] ->", df.iloc[0]["score"]) # now Niranjan, position 0 changed
▶ Output
Before sorting, position 0 is Rahul:
df.iloc[0] score -> 90
score
Niranjan 50
Viraj 70
Rahul 90
df.loc['Rahul'] -> 90
df.iloc[0] -> 50
Why: After the sort, loc['Rahul'] still returns 90 because the label “Rahul” travels with its row wherever it lands. But iloc[0] now returns 50, because position 0 is whatever row happens to be on top, which is Niranjan after sorting. If your code identifies a specific record, use its label with loc. Save iloc for “give me the top five” style work where position is genuinely what you mean.
Practice Exercises
Build the employees DataFrame from the boolean indexing example (name, age, dept, salary), then try these on your own:
- Exercise 1: Use
locto pull just thenameandsalarycolumns for everyone in the “Eng” department. - Exercise 2: Use
ilocto grab the last three rows and the first two columns in one call. - Exercise 3: Write the same filter (“age under 30 and salary above 70000”) twice: once with bracket boolean indexing, once with
query(). Confirm both return identical rows.
Conclusion
You now have the full pandas loc iloc toolkit. loc selects by label and its slices are inclusive, iloc selects by integer position and its slices are exclusive, and boolean indexing, query(), and isin() cover the filtering you will reach for every day. The one rule that saves the most debugging: use loc with a label when you mean a specific record, and save iloc for genuine “top five” or “last row” positional work, because positions shift the moment you sort.
Next up is Pandas data cleaning, where you will put these selectors to work fixing missing values and duplicates. For the full path from basics to machine learning, head to the Python + AI/ML tutorial series home.
Frequently Asked Questions
When should I use loc vs iloc in pandas?
Use loc when you know the row labels and column names, which covers most data analysis. Use iloc when you only care about position, like the first 10 rows or the last column. In day-to-day work, loc with a boolean mask handles the large majority of selection, and iloc shows up mainly for quick positional peeks.
Why does loc include the endpoint but iloc does not?
loc works with labels, where ‘A’ to ‘C’ naturally means A, B, and C, so the endpoint is kept. iloc follows Python’s own slicing rule, where the stop index is excluded, so 0:2 returns positions 0 and 1 only. The short version: loc is inclusive, iloc is exclusive.
What is the pandas query method and when should I use it?
df.query() filters using a string expression instead of bracket notation. It stays readable when conditions pile up: df.query(‘age > 25 and dept == “Eng”‘) is easier to scan than df[(df[‘age’] > 25) & (df[‘dept’] == ‘Eng’)]. Use the @ prefix to pass in a Python variable, for example df.query(‘salary >= @min_salary’).
Can I use loc and iloc on a pandas Series?
Yes. A Series has .loc and .iloc too. For a Series with a custom index, s.loc[‘label’] selects by label and s.iloc[0] selects by position. The same inclusive loc and exclusive iloc rules apply when you slice.
What is the difference between loc, at, and iat?
loc and iloc can return a single value, a Series, or a whole DataFrame. at and iat are stripped-down accessors built for one single cell, so they are faster for that job. Use df.at[‘Viraj’, ‘score’] for a label-based cell and df.iat[2, 0] for a position-based cell. Reach for at and iat in tight loops, and loc or iloc everywhere else.
How do I set values without the SettingWithCopy warning in pandas 2.3.3?
Always assign through a single loc or iloc call, like df.loc[df[‘age’] > 25, ‘bonus’] = 1000. Pandas 2.3.3 makes Copy-on-Write the default, so this updates the original DataFrame cleanly. Avoid chained indexing such as df[df[‘age’] > 25][‘bonus’] = 1000, because that targets a temporary copy. Under Copy-on-Write pandas raises a ChainedAssignmentError warning and leaves the original DataFrame unchanged.
Interview Questions on Pandas loc and iloc
The same ideas as they show up in real interviews, framed as scenarios you can practice out loud.
Q: What is the core difference between loc and iloc?
loc selects by label, meaning the row and column names you can see in the DataFrame. iloc selects by integer position, counting from 0 like a Python list. The memory hook: L for Label, I for Integer.
Q: How does slicing behave differently between loc and iloc?
loc slices are inclusive on both ends, so df.loc['A':'C'] returns rows A, B, and C. iloc slices follow Python’s own rule and exclude the stop index, so df.iloc[0:3] returns positions 0, 1, and 2 only. Mixing these up is the single most common pandas selection bug.
Q: When would you prefer query() over bracket boolean indexing?
Use query() when a filter has several conditions, because it reads like a sentence and drops the repeated df[...] noise and the parentheses that bracket boolean indexing requires. For example df.query("dept == 'Eng' and salary > 72000") is easier to scan than the bracket form. You can pull in a Python variable with the @ prefix, like df.query("salary >= @min_salary").
Q: A colleague sorts a DataFrame and then reads df.iloc[0] expecting a specific record, but the value is wrong. What do you tell them?
Sorting reorders the rows, so position 0 now points at whatever row landed on top, not the original record. Labels stay glued to their data, positions do not. The fix is to identify the record by label with loc, for example df.loc['Rahul'], and reserve iloc for cases where the position itself is the thing they actually want.
Q: You need to update a subset of rows and pandas raises a ChainedAssignmentError. What went wrong and how do you fix it?
Chained indexing such as df[df['age'] > 25]['bonus'] = 1000 targets a temporary copy, so under the Copy-on-Write default in pandas 2.3.3 the original DataFrame is left unchanged and pandas warns you. Assign through a single loc call instead: df.loc[df['age'] > 25, 'bonus'] = 1000. That selects the rows and the column in one step and writes back to the real DataFrame.
Q: When would you reach for at or iat instead of loc or iloc?
at and iat access a single scalar cell and are faster than loc and iloc for that one job. Use df.at['Viraj', 'score'] for a label-based cell and df.iat[2, 0] for a position-based cell, especially inside tight loops. For anything that returns a Series or a whole DataFrame, stick with loc or iloc.
Q: Why do you need parentheses and the & operator when combining two conditions in bracket boolean indexing?
Pandas compares whole columns at once and returns a boolean Series, so you must use the bitwise operators &, |, and ~ rather than the Python keywords and, or, and not. Because those bitwise operators bind more tightly than the comparisons, each condition needs its own parentheses, as in df[(df['dept'] == 'Eng') & (df['salary'] > 72000)]. Leaving them out raises an error or silently returns the wrong rows.
Further reading: pandas official documentation is the authoritative source on this.
Related Posts
Previous: Pandas Introduction: Series, DataFrame, Reading Data
Next: Pandas: Data Cleaning, Missing Values and Duplicates
Series Home: Python + AI/ML Tutorial Series

No comment