Indexing: loc vs iloc
loc vs iloc
Core Principle: Data selection is bifurcated into
.loc[]for label-based selection (where endpoint boundaries are inclusive) and.iloc[]for integer positional selection (where endpoint boundaries are half-open).
The two rules
df.loc["TP53":"EGFR"] # INCLUSIVE of "EGFR" ← unlike everything else in Python
df.iloc[0:3] # excludes position 3 ← like Python slicing
The .loc inclusivity is deliberate: with labels, "up to and including EGFR" is what you mean, and there is no "the label after EGFR" to write instead. It is still the thing people forget.
The full syntax
df.loc[row_labels, col_labels]
df.iloc[row_positions, col_positions]
df.loc["TP53"] # one row → Series
df.loc[["TP53", "EGFR"]] # several rows → DataFrame
df.loc["TP53", "log2fc"] # a scalar
df.loc[:, "log2fc"] # a column
df.loc[df["padj"] < 0.05] # boolean mask
df.loc[df["padj"] < 0.05, ["gene", "log2fc"]] # mask + columns, ONE call
df.loc[lambda d: d["padj"] < 0.05] # callable: works inside a chain
df.iloc[0] # first row
df.iloc[-1] # last row
df.iloc[0:5, 0:3]
df.iloc[[0, 2, 4], [1, 3]] # position lists: cross-product, unlike NumPy
df.iloc[:, -1] # last column
Note that .iloc[[0,2],[1,3]] gives a submatrix, whereas NumPy's a[[0,2],[1,3]] pairs the indices. pandas is doing the np.ix_ thing for you. See Fancy Indexing.
Why bare df[...] is ambiguous
df["gene"] # a COLUMN
df[0:3] # ROWS by position
df[df["x"] > 1] # ROWS by mask
df[["a", "b"]] # COLUMNS
Four different meanings depending on what you pass. It is convenient interactively and a liability in scripts. Use .loc and .iloc in anything you will re-read.
The trap is sharpest with an integer index:
np.int64(10)
Chained indexing: the pandas 3.0 change
# BROKEN: silently does nothing in pandas 3.0
df[df["padj"] < 0.05]["sig"] = True
# CORRECT
df.loc[df["padj"] < 0.05, "sig"] = True
The first form selects a subset (which under Copy-on-Write is always a new object), then assigns to that temporary, which is discarded. In pandas 2.x this raised SettingWithCopyWarning. In pandas 3.0 the warning is gone: because the behaviour is now defined and consistent: so the failure is completely silent.
The rule: if there is an = in the statement, there must be exactly one [] on the left, and it must belong to .loc or .iloc.
Setting values
df.loc[mask, "col"] = value
df.loc[mask, ["a", "b"]] = [1, 2]
df.iloc[0, 2] = value
df.at["TP53", "log2fc"] = 1.5 # scalar only: faster
df.iat[0, 2] = 1.5 # positional scalar: faster
.at and .iat bypass the general machinery for single-scalar access. Meaningfully faster in a loop, though if you are in a loop you should probably be vectorising. See pandas Performance.
Enlargement
.loc can create new labels; .iloc cannot.
df.loc["NEWGENE"] = [...] # adds a row
df.loc[:, "newcol"] = 0 # adds a column
df.iloc[100] = [...] # IndexError if there is no position 100
Mixing labels and positions
You cannot in one call. Convert:
df.loc[df.index[0:3], "gene"] # positions → labels
df.iloc[:, df.columns.get_loc("gene")] # label → position
df.iloc[0:3][["gene"]] # two steps (fine for reading)
Common patterns
# top N by a column, keeping all columns
df.nlargest(20, "log2fc")
df.sort_values("padj").iloc[:20]
# a random subsample
df.sample(n=1000, random_state=42)
# a specific set of genes, in a specific order
df.loc[PANEL_GENES] # raises if any are missing
df.reindex(PANEL_GENES) # fills NaN for missing: often what you want
# every column except one
df.loc[:, df.columns != "gene"]
df.drop(columns=["gene"])
df.loc[list_of_labels] raising on a missing label is a feature: it catches typos and stale gene lists. Use reindex when absence is expected and NaN is an acceptable answer.
Bioinformatics examples
expr = pd.read_csv("counts.tsv", sep="\t", index_col=0) # genes × samples
expr.loc["TP53"] # one gene, all samples
expr.loc[PANEL, TREATED_SAMPLES] # submatrix by label
expr.iloc[:, 0:3] # first three samples
# filter genes and keep the labels aligned: the whole point of pandas
keep = (expr > 1).sum(axis=1) >= 3
filtered = expr.loc[keep]
# a genomic window from a position-indexed frame
v = variants.set_index("pos").sort_index()
v.loc[7_670_000:7_680_000] # inclusive of both ends
That last line is where .loc inclusivity is actually helpful: a genomic range is conventionally closed, and .loc matches that convention. See Coordinate Systems.
Common mistakes
- Chained assignment. Silent no-op in pandas 3.0. The most important item on this list.
- Forgetting
.locslices are inclusive. - Bare
df[0:3]meaning rows whiledf["a"]means a column. .locwith an integer index meaning label, not position.df.loc[missing_label]raising where you expected NaN. Usereindex.df.iloc[mask]with a boolean pandas Series..ilocwants positions or a plain NumPy bool array; passmask.to_numpy()or use.loc..aton a non-unique index: undefined which row you get.
See also
DataFrame · Series · Index Objects · Copy-on-Write · Filtering and query · Indexing and Slicing
Exercise
Write compare_slices(df, start, end). The frame is indexed by unique gene
labels in file order and has one column, value. start and end are labels.
Return a list of report lines that shows how the two indexers differ on the same
pair of endpoints.
Return ["missing"] if either label is absent from the index, and
["reversed"] if start sits at a later position than end. Otherwise return
exactly two lines: loc rows=<n> sum=<total> for the label slice
start:end, and iloc rows=<n> sum=<total> for the positional slice running
from the position of start to the position of end. Format both totals to two
decimal places. When start and end are the same label the two lines must
disagree, because the label slice includes its endpoint and the positional slice
does not.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | BRCA1 KRAS gene value TP53 10.5 BRCA1 20.0 EGFR 30.25 KRAS 40.0 MYC 50.5 | loc rows=3 sum=90.25 iloc rows=2 sum=50.25 |
| 2 | stdin | EGFR EGFR gene value TP53 10.5 BRCA1 20.0 EGFR 30.25 KRAS 40.0 | loc rows=1 sum=30.25 iloc rows=0 sum=0.00 |
| 3 | stdin | KRAS BRCA1 gene value TP53 10.5 BRCA1 20.0 EGFR 30.25 KRAS 40.0 | reversed |
| 4 | stdin | TP53 SEPTIN9 gene value TP53 10.5 BRCA1 20.0 | missing |
Hints · 2
01Hint
Index.get_loc(label) converts a label to its integer position, which is how you feed .iloc from labels.
02Hint
df.iloc[i:j, df.columns.get_loc("value")] keeps you positional on both axes instead of mixing a label into .iloc.