kmail.at
contact

elsewhere

EnglishEN
← learning

pandas · difficulty ◆◆

pandas map() — translate values with a dict, a Series or a function

map() is a value-for-value translation, not a join: hand it a dict, a Series or a callable and every element comes back swapped — with a silent NaN wherever the lookup found nothing.

pandas will map your region codes to nothing and hand you NaN without a word. There is no KeyError in a dict lookup — that silence is both the feature and the trap.

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

2026-10-16 · 5 min read

$ series.map()
2.1.0
DataFrame.map() landed

August 2023 — the same release that deprecated DataFrame.applymap and Styler.applymap (GH 52353).

removed
applymap in pandas 3.0.3

hasattr(pd.DataFrame, 'applymap') is False — deprecated in 2.1.0, then deleted.

4 kinds
func accepts

callable, dict, Series or None, straight from the signature: Callable | Mapping | Series | None.

engine=, **kwargs
Series.map() gained

Both marked .. versionadded:: 3.0.0 in the docstring — the callable can now run through a numba, bodo or blosc2 JIT decorator.

What it does

map takes one value in and gives one value out, over every element of a Series, and the index comes back unchanged. The argument decides the mode: a dict is a key lookup, a Series is a key lookup against that Series' index, and a callable is just run on each element. A miss returns NaN, not an error. na_action='ignore' keeps NaN out of the callable entirely. In pandas 2.1 the same name was extended to the whole frame as DataFrame.map, the elementwise applymap successor, so one method name now covers both a single column and every cell of a DataFrame. There is no axis parameter, no multi-column logic and no aggregation.

Why it matters

Half the cleaning work in a real pipeline is translation: region codes to names, SKU to price, 'wien ' / 'VIENNA' / 'Wien' to one spelling, a letter grade to a number. map is the cheapest way to do it when the lookup table is already in your process — no merge, no second frame to manage, no temporary key column, and the output lands back on the original index. It also keeps the derivation readable: the mapping is data (a dict literal you can hand to a colleague), not a hidden if-elif ladder inside a function.

Example

$ import pandas as pd

orders = pd.DataFrame({
    "order_id": [1001, 1002, 1003, 1004, 1005],
    "region_code": ["AT-W", "AT-E", "DE-BY", "AT-W", "de-nw"],
    "units": [12, 4, 30, 7, 19],
})

region_names = {"AT-W": "Austria West", "AT-E": "Austria East",
                "DE-BY": "Germany Bavaria"}
orders["region"] = orders["region_code"].map(region_names).fillna("Unmapped")
print(orders)
   order_id region_code  units           region
0      1001        AT-W     12     Austria West
1      1002        AT-E      4     Austria East
2      1003       DE-BY     30  Germany Bavaria
3      1004        AT-W      7     Austria West
4      1005       de-nw     19         Unmapped

The job I do most weeks: a report arrives with region codes and the label table lives in a config dict. map translates the whole column in one array-level lookup, and the fillna catches the one code nobody told me about. Note the lowercase de-nw — map does not normalise case, it only matches exactly.

$ import pandas as pd

orders = pd.DataFrame({
    "order_id": [1001, 1002, 1003, 1004, 1005],
    "sku": ["A-100", "B-200", "C-300", "A-100", "B-200"],
    "units": [12, 4, 30, 7, 19],
})

catalog = pd.DataFrame({
    "sku": ["A-100", "B-200", "C-300"],
    "unit_price": [19.90, 24.50, 9.99],
}).set_index("sku")

orders["unit_price"] = orders["sku"].map(catalog["unit_price"])
orders["revenue"] = (orders["units"] * orders["unit_price"]).round(2)
print(orders)
   order_id    sku  units  unit_price  revenue
0      1001  A-100     12       19.90    238.8
1      1002  B-200      4       24.50     98.0
2      1003  C-300     30        9.99    299.7
3      1004  A-100      7       19.90    139.3
4      1005  B-200     19       24.50    465.5

When the lookup table already exists as a DataFrame, index it and map the column straight onto it. catalog['unit_price'] is a Series whose index is the SKU, so the lookup is by key, not by position. No merge, no join, and the row order of orders survives untouched.

$ import pandas as pd

cities = pd.Series(["  vienna ", None, "GRAZ", "linz  "])

print(cities.map(lambda v: v.strip().title(), na_action="ignore"))
0    Vienna
1       NaN
2      Graz
3      Linz
dtype: str

String cleaners break on NaN - the plain call raises AttributeError: 'float' object has no attribute 'strip'. na_action='ignore' hands NaN straight through without ever entering the lambda, and the result keeps pandas 3's str dtype.

$ import pandas as pd

sensors = pd.DataFrame({
    "sensor": ["T-01", "T-02", "T-03"],
    "temp_c": [21.4567, 19.8123, 22.0049],
    "humidity_pct": [48.2231, 51.9908, 44.5127],
})

qc = sensors[["temp_c", "humidity_pct"]].map(lambda v: round(v, 1))
print(qc)
   temp_c  humidity_pct
0    21.5          48.2
1    19.8          52.0
2    22.0          44.5

DataFrame.map is the applymap successor: one call, every cell, shape preserved. Selecting the numeric columns first is not optional - passing a float to round() on the city column raises TypeError. Note the dtypes: rounding did not turn the integer units column into floats.

Common flags

func
Callable, dict, Series or None — the annotation in pandas 3 is Callable | Mapping | Series | None, and all three do the same one-value-in-one-value-out job by different routes.
func={dict}
Pure key lookup. Keys not in the dict become NaN — no KeyError, no warning. Build it once outside a loop and reuse it across columns.
func=Series
The Series' index is the key set; its values are the payload. Set it up with set_index() so the key is the thing you are matching on.
na_action='ignore'
NaN bypasses the mapping completely instead of being passed in. Only 'ignore' and None are accepted — anything else raises ValueError.
engine=
New in 3.0.0, callable path only: pass a JIT decorator such as numba.jit or bodo.jit to run the function through a compiler. Passing a mapping together with engine raises.
**kwargs
Also new in 3.0.0 — extra keyword arguments are forwarded to your function, so no more wrapping a lambda around a one-argument signature.
df.map(func)
DataFrame.map applies the callable to every cell and returns the same shape. Select the numeric columns first — a string cell going into round() raises TypeError.

History

applymap becomes map (2.1.0, August 2023)

DataFrame.applymap worked for a decade and had one problem: nobody could remember which of apply and applymap was elementwise. pandas 2.1.0 added DataFrame.map and deprecated applymap in the same release (GH 52353), arguing the new name 'better communicates that this is the DataFrame version of Series.map'. The rename reached further than DataFrames — Styler.applymap and Styler.applymap_index became Styler.map and Styler.map_index in that release too. In pandas 3.0.3 applymap is gone: hasattr(pd.DataFrame, 'applymap') is False.

The era when your function ran twice

Until 1.1.0 (2020), apply and applymap invoked the callable twice on the first row or column: once to sniff the return dtype, once for real. A print() inside the function showed the first row printed two times, and anyone with a counter or an API call inside the function got a doubled first element. The 1.1.0 release notes list the fix plainly — 'apply and applymap on DataFrame evaluates first row/column only once'. The other slow repair was na_action: it was unimplemented or outright wrong for most ExtensionArray types until 2.1.0, where map on a categorical column with na_action='ignore' raised NotImplementedError.

Under the hood

Why a dict lookup never calls Python per row

dict and Series mappers take a completely different route from callables, inside pandas/core/algorithms.py::map_array. A plain dict is converted to a Series — values as data, keys as the index — and then the lookup is one array operation: mapper.index.get_indexer(arr) followed by take_nd. That is a hashtable probe over the whole column at once, so 'expensive dictionary lookup' folklore applies to Python dicts in a loop, not to map. A callable takes the slow lane: the Series is cast to object dtype and handed to lib.map_infer, a Cython loop that calls back into the interpreter once per element; with na_action='ignore' it switches to lib.map_infer_mask with a mask precomputed by isna(), so NaN is skipped outside the loop. And if the mapper is a dict subclass defining __missing__ (a defaultdict), pandas cannot use get_indexer at all — it wraps the dict in a lambda that indexes it, which is why the default-value trick costs you the fast path (GH 15999).

Fun facts

Pros & cons

pros

  • + The shortest path from a code column to a label column: one call, index preserved, no merge and no helper key.
  • + The dict/Series route is an array-level hash lookup (get_indexer + take_nd), not a Python function call per row.
  • + One mental model for a column and a whole frame — Series.map and DataFrame.map are the same idea, and DataFrame.map retired applymap.

cons

  • − A missing key produces NaN silently. Nothing tells you a code went unmapped unless you check isna() yourself.
  • − The callable route is the slow lane: it casts to object and loops in Cython, one Python call per element.
  • − No axis, no second column, no aggregation — per-row logic across columns is still apply(axis=1) or a merge.

Takeaways

  1. 1Reach for map when the translation is a value-for-value lookup you already hold in memory — a dict of codes, a price column, a naming convention.
  2. 2A dict map never raises on a missing key, so end it with .fillna(...) or pass a defaultdict; otherwise the blanks ship to the report.
  3. 3When the mapper is a Series, its index is the key set. Build it with set_index() — a positionally built lookup returns all NaN.
  4. 4Pass na_action='ignore' for any string-based callable; NaN is then skipped by a precomputed mask instead of crashing your lambda.
  5. 5DataFrame.map is elementwise and replaces applymap — if the rule needs two columns of the same row, you want apply(axis=1).

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