Index Objects
In one line: the row (and column) labels — an immutable, array-like object that drives alignment, joins and lookups.
What an Index is
idx = pd.Index(["TP53", "BRCA1", "EGFR"], name="gene")
idx.name; idx.dtype; idx.is_unique; idx.is_monotonic_increasing
idx.tolist(); idx.to_numpy()
"TP53" in idx # hash-based membership — O(1)
idx.get_loc("BRCA1") # → 1
It is immutable. idx[0] = "X" raises. To change labels you build a new Index (df.index = [...], df.rename(...)).
Both axes have one: df.index for rows, df.columns for columns. They are the same class, which is why df.columns.str.startswith("sample_") works.
The three jobs an Index does
1. Alignment. Every binary operation between two pandas objects aligns on the index first. This is the source of both pandas' power and its most confusing behaviour. See Series.
2. Fast lookup. A unique Index builds a hash table, so df.loc["TP53"] is O(1) rather than a scan. On a 20,000-gene frame that is the difference between instant and noticeable.
3. Metadata. The index says what each row is. expr.loc["TP53", "sample_3"] is self-documenting in a way expr[4711, 2] never is.
Index types
| Type | Use |
|---|---|
Index |
general (str, object) |
RangeIndex |
default 0..n-1; stores only start/stop/step, so it is free |
DatetimeIndex |
time series; enables .loc["2026-01"] partial-string slicing |
CategoricalIndex |
repeated labels, memory-efficient |
IntervalIndex |
ranges — genomic intervals! see below |
MultiIndex |
hierarchical — see MultiIndex |
Setting and resetting
df.set_index("gene") # a column becomes the index
df.set_index(["chrom", "pos"]) # → MultiIndex
df.reset_index() # index becomes a column again
df.reset_index(drop=True) # discard it entirely
df.rename_axis("gene_id") # rename the index itself
df.index = new_labels # direct assignment
When to set an index: when you will repeatedly look up by that key, when you will align two frames on it, or when it genuinely identifies the row. A gene ID on an expression matrix, yes. An arbitrary row number, no — leave the RangeIndex.
reset_index(drop=True) after filtering is a habit worth forming. Otherwise your index has gaps (0, 3, 7, 11...), which is harmless until something downstream assumes positions and labels agree.
Uniqueness matters
df.index.is_unique
df.index.duplicated().sum()
df[df.index.duplicated(keep=False)] # show all duplicate rows
A non-unique index makes .loc["TP53"] return a DataFrame instead of a Series, which then breaks the code expecting a scalar. Worse, joins on a non-unique key multiply rows. Check uniqueness after any operation that could introduce duplicates.
Set operations
a.index.intersection(b.index) # genes in both
a.index.union(b.index)
a.index.difference(b.index) # genes only in a
a.index.isin(panel) # boolean mask
df.reindex(new_index) # conform to a new index; NaN for missing
df.reindex(new_index, fill_value=0)
a.align(b, join="inner", axis=0) # align two frames explicitly
intersection is the clean way to find shared genes across datasets — much better than converting to sets and losing order.
reindex is subtly different from .loc[]: reindex tolerates missing labels (fills NaN), .loc with a list of missing labels raises. Use reindex when you want to force a specific ordering, e.g. matching a metadata table's sample order.
IntervalIndex for genomic ranges
bins = pd.IntervalIndex.from_breaks([0, 1e6, 2e6, 3e6], closed="left")
binned = pd.cut(variants["pos"], bins)
variants.groupby(binned, observed=True).size()
pd.cut plus an IntervalIndex gives you binning with readable labels. For real interval overlap queries (which genes does this variant fall in?), pandas is the wrong tool — use pyranges, bioframe, or an interval tree. See Applied - GFF and Genomic Intervals.
Alignment in action
expr = pd.read_csv("counts.tsv", index_col=0) # genes × samples
meta = pd.read_csv("samples.tsv", index_col=0) # samples × attributes
# align samples explicitly rather than assuming the order matches
common = expr.columns.intersection(meta.index)
expr = expr[common]
meta = meta.loc[common]
assert (expr.columns == meta.index).all()
That assertion is worth writing every time. Sample order mismatch between a count matrix and a metadata table is a classic, silent, result-inverting bug — you compare the wrong groups and get a beautiful, wrong figure.
Performance
df.index.is_monotonic_increasing # sorted?
df.sort_index() # sort — enables faster slicing
.loc on a sorted index can binary-search; on an unsorted non-unique index it must scan. For large frames you slice repeatedly, sorting once pays for itself.
Common mistakes
- Duplicate index values silently changing
.locreturn types and multiplying join output. - Assuming two frames' rows correspond because they have the same length. Align explicitly.
- Forgetting
reset_index(drop=True)after filtering, leaving gaps. df.index = list(...)of the wrong length → ValueError, or worse, silently wrong if lengths coincidentally match.- Setting an index you then need as a column.
.reset_index()gets it back, but grouping and merging are often easier with it as a plain column. - Expecting the index to survive
to_csvwithoutindex=True, or getting a spuriousUnnamed: 0column when reading back withoutindex_col=0.
See also
Series · DataFrame · MultiIndex · loc vs iloc · Merging and Joining · Applied - GFF and Genomic Intervals