kmail.at
contact

elsewhere

EnglishEN
← learning

pandas · difficulty ◆

pandas iat[] — read or write one cell by position

Two integers, one cell — iat is the positional scalpel of pandas indexing.

Every pandas indexer takes slices, lists, masks — except one. iat takes two integers and nothing else, and that refusal is the feature.

fully AI-generatedThis text is fully AI-generated and was published without human editorial review.

2026-08-31 · 4 min read

$ df.iat[]
0.11
Positional scalar accessor since

April 22, 2013, alongside .loc/.iloc/.at

2
keys it accepts

row + column positions, both integers — nothing else parses

~9.5µs
measured here (100k rows)

per read via .iat, vs ~13.7µs .iloc / ~19µs .loc — pandas 3.0.5

~375ms vs ~2ms
50k-cell fill, loop vs vectorized

df.iat per-cell loop vs one f.loc[idx, 'col'] = True call — same frame

What it does

df.iat[r, c] reads or writes exactly one cell by integer position — labels, slices, and masks are all off the table. df.iat[0, 1] hands back a single scalar (an np.float64 under pandas 3), and df.iat[0, 1] = 25.0 writes straight into the parent frame. Negative positions work: df.iat[-1, 0] is last row, first column. Series join the party one axis smaller — s.iat[1] reads, s.iat[1] = 99 writes. Anything beyond a scalar pair raises 'iAt based indexing can only have integer indexers'; a position past the end raises IndexError — unlike .at, iat never enlarges the frame.

Why it matters

The workhorse scenario: you computed WHERE to fix (row position from argmax or a loop counter), and you want to fix THAT spot without dragging labels around. iat is the shortest honest write in modern pandas — no chained-assignment gray zones under Copy-on-Write, no dtype guessing. It is also the fastest single-cell read in the indexing family: ~9.5 µs per lookup on a 100k-row frame versus ~13.7 µs for .iloc and ~19 µs for .loc (measured on pandas 3.0.5 this morning). The discipline is the hidden feature: if your 'iat' call contains a list or a variable that might be a label, you are in the wrong method — and pandas tells you immediately instead of returning something subtly wrong.

Example

$ df = pd.DataFrame({'sensor': ['RX-1', 'RX-2', 'TX-4', 'TX-9'],
...     'temp_c': [21.4, 19.8, 23.1, 18.6],
...     'batt':   [92, 87, 55, 99]},
...     index=['a1', 'a2', 'a3', 'a4'])
>>> df.iat[0, 1]
np.float64(21.4)

Two integers in, one scalar out. df.loc['a1', 'temp_c'] or df.at['a1', 'temp_c'] return the same value — iat is just the positional way in, with the least machinery attached.

$ hot = df.temp_c.to_numpy().argmax()   # -> 2: the hottest sensor is row 2
>>> df.iat[hot, 1] = 25.0              # re-check confirmed: overwrite it
>>> df.temp_c.to_list()
[21.4, 19.8, 25.0, 18.6]

THE iat pattern: compute a position (here from argmax), write that one cell. Because iat writes into the parent frame, the fix survives pandas 3's Copy-on-Write — and .iloc[2, 1] / .at['a3', 'temp_c'] cross-checks read np.float64(25.0) identically.

$ s = pd.Series([10, 20, 30], index=['x', 'y', 'z'])
>>> s.iat[1] = 99
>>> s.to_list()
[10, 99, 30]

One axis, same contract: on a Series one integer addresses one element. df.batt.iat[3] is the same idea on a column — it returned np.int64(99).

$ df.iat[[0, 1], 0]
ValueError: iAt based indexing can only have integer indexers

iat refuses lists, slices, and masks outright. The positional tools for those are df.iloc[[0, 1], 0] and friends — iat is strictly one cell per call.

$ df.iat['a3', 1]
ValueError: iAt based indexing can only have integer indexers

The classic mix-up: 'a3' is a LABEL. Same ValueError either way — the door doesn't even open, so the mistake can't happen silently.

$ df.iat[4, 1]
IndexError: index 4 is out of bounds for axis 0 with size 4

Out of bounds raises, frame untouched — .at would have enlarged the frame by a row here. iat is the stricter twin.

$ df.iat[-1, 0]
'TX-9'

Negative positions work like numpy's: -1 is the last row. df.iat[-1, 0] is a tidy 'last row, first column' read.

Common flags

df.iat[r, c]
read one cell by row/column POSITION — pure ints, scalar out
df.iat[r, c] = x
write one cell in the parent frame — the CoW-proof single-cell write by position
df.iat[-1, 0]
negative positions work, numpy-style: -1 is the last row
s.iat[i]
Series form — one integer, one element (same for reads and writes)
df.iat[[0, 1], 0]
ValueError 'can only have integer indexers' — lists and masks belong to .iloc
df.iat['a3', 1]
ValueError — labels belong to .at, never iat (the most common iat mistake)
df.at['a3', 'temp_c']
the label twin — identical scalar contract, first hash-resolves the label before the same final read

History

Born with loc, iloc and at in pandas 0.11 (April 22, 2013)

The 0.11.0 'Selection choices' release notes built the modern indexing vocabulary: .loc for labels, .iloc for positions, and their scalar-accessor twins .at and .iat. I pulled the 0.19.0 source off PyPI to confirm the lineage: pandas/core/indexing.py there defines _iAtIndexer as a subclass of _ScalarAccessIndexer with _takeable = True and the exact, unchanged error string 'iAt based indexing can only have integer indexers'. The class in today's pandas 3 implementation is still _iAtIndexer, still with that message.

Replacements, retirements, and Copy-on-Write

0.20.0 (May 5, 2017) deprecated the ancient .iget_value method 'Use .iat[i, j] instead' — the accessor became the officially blessed path for positional single cells. 0.21.0 (October 27, 2017) deprecated get_value()/set_value() in favor of .at/.iat, and pandas 1.0.0 (January 29, 2020) removed those methods outright. In the pandas 3 era, iat is also a clean write under Copy-on-Write: it touches the parent frame directly, no chained-assignment gray zones.

Under the hood

Under the hood: two ints, zero lookups

_iAtIndexer._convert_key checks is_integer(i) on each key part — that's where the ValueError from examples 4 and 5 is thrown. Then the shared base class (_ScalarAccessIndexer.__getitem__) calls obj._get_value(r, c, takeable=True). With takeable=True, the frame skips label resolution entirely and walks straight to the block manager; with takeable=False (that's .at) it first runs index.get_loc(label) — a hash lookup per axis — before the same final read. That's the entire measured iat-vs-at/iloc gap: numpy indexing versus label-resolution overhead. The writer follows the same split: _ScalarAccessIndexer.__setitem__ requires a full scalar tuple, then calls _set_value(takeable=True) — no row enlargement, and pandas 3's dtype rules refuse mismatches instead of upcasting silently.

Fun facts

Pros & cons

pros

  • + Fastest single-cell read of the family — ~9.5µs vs ~13.7µs (.iloc) / ~19µs (.loc) per read on a 100k-row frame
  • + Refuses lists, slices, and labels loudly — iat can't quietly return a whole row Series on a mistake
  • + Writes land in the parent frame: clean under Copy-on-Write, perfect for fixing one cell after a validation pass

cons

  • − One cell per call — filling 50k cells in an iat loop measured ~375ms vs ~2ms for one vectorized .loc call
  • − IndexError on out-of-bounds, no enlargement — the few legit 'grow the frame' writes must go through .at
  • − Integers only: no labels, no slices — anything fancier belongs to .iloc, and .iat[i] on a Series trips people used to label Series access

Takeaways

  1. 1iat = position, at = label — same scalar contract; pick by whether you're holding ints or labels
  2. 2In a loop, s.iat[i] / df.iat[i, col_pos] is the quick element poke — but bulk writes want one vectorized call
  3. 3iat never enlarges the frame; IndexError on out-of-bounds is a feature, not a bug
  4. 4Any list, slice, or label key raises ValueError instantly — iat mistakes are loud, not silent
  5. 5Compute-a-position-then-write (argmax, argmin, loop counter) is the pattern iat was born for

Related commands

archive entry — written and published by a scheduled job, never editorially reviewed. Kept for reference: out of the site’s search index, and no ads run here. the reviewed library →

← all learning