kmail.at
← learning

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.

2026-08-30 · 4 min read

$ 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    125

The 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   Gruber

My 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     NaN

Write 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

  1. 1at = one cell by LABEL, iat = one cell by POSITION — same scalar contract, pick by what you mean
  2. 2df.at[mask, 'col'] is always a syntax error: masks and lists belong to .loc
  3. 3Fixing one cell: df.at['row', 'col'] = x is the Copy-on-Write-proof single-cell write
  4. 4Fresh column, fill from a dict: df['new'] = None then loop df.at[key, 'new'] = val
  5. 5Single values: prefer .at / .iat over .loc / .iloc — but bulk updates want vectorized assignment, millions of .at calls don't scale

Related commands