pandas · difficulty ◆
pandas at[] — read and write exactly one cell
One row label, one column name, one value — at[] is the scalpel of the indexing family.
Two keys, zero slices, one value — .at has been the narrowest API in pandas since 2013, and that narrowness is exactly the feature.
fully AI-generatedThis text is fully AI-generated and was published without human editorial review.$ df.at[]What it does
df.at[row, col] reads or writes exactly one cell, addressed by label — df.at['Tokyo', 'revenue'] hands back a single scalar, not a Series. Its positional twin df.iat[2, 1] addresses the same cell by integer position. That's the entire API surface: neither accepts a list, a slice, or a boolean mask — anything beyond a scalar pair raises ('Invalid call for scalar access'). Both work on DataFrame and on Series — s.at['Vienna'] on the 'region' column is one label, one scalar — and both are accessors, not methods: no parentheses anywhere. The label fast path requires unique axes; with duplicate labels .at transparently falls back to .loc semantics (verified in example 6), a bugfix lineage that goes back to GH#33041 in pandas 1.1.0.
Why it matters
Single-cell access is everywhere in data glue code: a config value in a lookup table, one corrected sensor reading, one exchange rate in a conversion loop. Reaching for df.loc for those works, but pays for machinery you never use — masks, slices, the whole selection engine. Under pandas 3's Copy-on-Write, the honest one-cell write is df.at['row', 'col'] = x, and .iat[r, c] is the fastest way to touch a cell in a tight loop. There's one quiet perk too: assigning to a label that doesn't exist yet enlarges the frame (my go-to for 'insert one row into an object-dtype frame from a config dict'). Just remember that at is per-cell: about 9 µs per access on a 100k-row frame — a plain column lookup beats it, and vectorized beats everything.
Example
$ df.at['Tokyo', 'revenue']np.int64(3990)One row label + one column name -> one scalar back. df.loc['Tokyo', 'revenue'] gets you the same np.int64(3990), just with more machinery in the way.
$ df.at['Berlin', 'units'] = 125 # morning stock recount
df.loc[['Berlin'], ['units']] units
Berlin 125The daily fix-one-cell move: no mask, no chained-assignment doubt — the write goes straight into the parent frame, which is the only thing that survives pandas 3's Copy-on-Write.
$ df['manager'] = None # object dtype from the start
for city, m in managers.items():
df.at[city, 'manager'] = m
df.loc[['Berlin', 'Vienna'], ['manager']] manager
Berlin Müller
Vienna GruberMy favorite at pattern: initialize a column as None (object dtype), then fill it cell by cell from python dicts.
$ df.iat[2, 0] = 215 # Tokyo stock recount, by position
df.iat[2, 0]np.int64(215)iat is the positional twin: same scalar-in/scalar-out contract, but pure positions — no labels involved. df.loc['Tokyo', 'units'] reads np.int64(215) identically.
$ df.at[['Berlin', 'Paris'], 'units']InvalidIndexError: ['Berlin', 'Paris']The whole point: at REFUSES anything but a scalar pair. Two rows on one line? You wanted .loc['Paris':'Tokyo'] or a mask.
$ df.at['Prague', 'revenue'] = 770
df.tail(2) units revenue region manager
Vienna 95.0 1615.0 EU Gruber
Prague NaN 770.0 NaN NaNWrite to an unseen label and the frame grows a row. Useful for one-off insertion on object-dtype frames; mass insertion still belongs to concat.
$ d3 = df.copy()
d3.at['Paris', 'units'] = 'many'TypeError: Invalid value 'many' for dtype 'float64'pandas 3 honesty: .at in-place refuses to stuff a string into a float64 column instead of silently upcasting the whole column. Fix the dtype first (astype, or the None trick from example 3).
Common flags
- df.at['row', 'col']
- label-based single-cell read or write — the task it exists for
- df.iat[r, c]
- positional single cell — pure ints, same scalar contract
- df.at[row, col] = x
- assignment form — passes through to the frame, no copy semantics in the way
- df.at['new', 'col'] = x
- unseen row label -> frame enlargement (column must already exist)
- s.at['label']
- one label on a Series returns that scalar — works the same
- df.at[5, 'col']
- label 5, NOT position 5 — on int-labeled indexes only .iat is unambiguous
- df.at[mask, 'col']
- ValueError 'Invalid call for scalar access' — .at is strictly scalar, masks go to .loc
History
Born with loc and iloc — 0.11, April 22, 2013
The 0.11.0 'Selection choices' release notes built the modern indexing vocabulary: .loc for labels, .iloc for positions, .ix for the leftover guesswork. I pulled the 0.11.0 source from PyPI to check the claim: pandas/core/indexing.py already registers _AtIndexer ('label based scalar accessor') and _iAtIndexer ('integer based scalar accessor') in get_indexers_list(), both subclasses of a shared _ScalarAccessIndexer. The share-everything base class is why their error messages still mirror each other ('iAt based indexing can only have integer indexers').
The retirements that made at/iat load-bearing
0.21.0 (October 27, 2017) deprecated the old get_value()/set_value methods 'in favor of using .iat[] or .at[]' (GH#15269) — the single-cell accessors became the officially blessed replacement. pandas 1.0.0 (January 29, 2020) then removed get_value/set_value outright, and 1.1.0 (July 28, 2020) fixed .at's behavior on non-unique indexes (GH#33041), codifying today's fall-back-to-.loc semantics. Thirteen years in, the narrowest indexer in pandas is also one of the most stable.
Fun facts
Pros & cons
pros
- + Fastest single-cell read: ~7–9 µs per lookup vs ~10–14 µs for loc/iloc on a 100k-row frame
- + Un-enrollable mistakes: lists, slices, masks all raise — the accessor can't be misused into a silent multi-row slice
- + Obligingly chainable with .loc for surgical fixes: read with at, write back with at
- + None-initialized columns turn .at into a dict-to-frame row filler — one cell per line, object dtype from the start
cons
- − Only one cell per call — anything list-like, sliced, or masked is InvalidIndexError territory
- − On an int-labeled index, df.at[5, 'col'] means label 5, not the 5th row — a classic off-by-one generator
- − The dupe-label fallback returns a whole row Series, and pandas won't warn you the fast path backed out
Takeaways
- 1at = one cell by LABEL, iat = one cell by POSITION — same scalar contract, pick by what you mean
- 2df.at[mask, 'col'] is always a syntax error: masks and lists belong to .loc
- 3Fixing one cell: df.at['row', 'col'] = x is the Copy-on-Write-proof single-cell write
- 4Fresh column, fill from a dict: df['new'] = None then loop df.at[key, 'new'] = val
- 5Single values: prefer .at / .iat over .loc / .iloc — but bulk updates want vectorized assignment, millions of .at calls don't scale