Pandas: Data Selection with loc, iloc, Boolean Indexing

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.

Key Differenceiloc Integer Positionloc Label Baseddf.loc[‘B’]Row with label Bdf.loc[‘B’:’D’]B, C, D (inclusive)df.loc[:, ‘name’]Column by namedf.loc[mask]Boolean filterdf.iloc[1]Row at position 1df.iloc[1:3]Positions 1,2(exclusive)df.iloc[:, 0]Column by positiondf.iloc[-1]Last rowloc slicing isINCLUSIVE on both endsiloc slicing isEXCLUSIVE on endPython loc vs iloc: Label vs Integer Selection, Inclusive vs Exclusive Slicing

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.

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

Operationloc (Label)iloc (Integer)
Single rowdf.loc["Rahul"]df.iloc[0]
Multiple rowsdf.loc[["Rahul","Viraj"]]df.iloc[[0,2]]
Row slicedf.loc["A":"C"] (inclusive)df.iloc[0:3] (exclusive)
Single columndf.loc[:, "age"]df.iloc[:, 1]
Row + Columndf.loc["Rahul", "age"]df.iloc[0, 1]
Boolean filterdf.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.

The Python way: Reach for 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:

  1. Exercise 1: Use loc to pull just the name and salary columns for everyone in the “Eng” department.
  2. Exercise 2: Use iloc to grab the last three rows and the first two columns in one call.
  3. 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.

Previous: Pandas Introduction: Series, DataFrame, Reading Data

Next: Pandas: Data Cleaning, Missing Values and Duplicates

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *