Skip to content

pandas map

Translate values one at a time, through a dictionary, a lookup series, or a function of your own.

Have you ever had a column full of codes — '1', '2', '3' — that you needed to turn into something a human can read? Or a column of numbers that you wanted to show with a dollar sign and commas? That is map, and it is one of the few Pandas methods whose name says exactly what it does. You hand it a translation table, and it walks down the series one value at a time, replacing each one with whatever your table says.

The translation table can take three shapes: a dictionary, another series, or a function. Those are not three different methods — people often learn one of the three and never discover the others. It is one method with one job, element-wise translation, and three ways to describe what you want.

Official documentation: Series.map and DataFrame.map

The arguments that earn their keep

s.map({'R': 'Real', 'N': 'Nominal'})   # a dict: keys in, values out
s.map(lookup_series)                   # a series: matched against its index
s.map(str.title)                       # any callable, one value at a time
s.map('{:,.2f}'.format)                # a bound method is a callable, too
s.map(clean, na_action='ignore')       # leave missing values alone

The only other argument worth knowing is na_action. Left alone, map passes missing values to your function like any other value, which is rarely what you want. Set it to 'ignore' and NaN goes in, NaN comes out, and your function never sees it. Anything else you pass as a keyword argument goes straight through to your callable, so df.map(round, ndigits=2) works the way you would hope.

A worked example, on real data

The Bank for International Settlements publishes residential property prices for sixty-one countries and regions, going back decades, and it is the dataset behind Bamboo Weekly #181. Each row is one country, one quarter, one measurement:

import pandas as pd

url = ('https://stats.bis.org/api/v2/data/dataflow/'
       'BIS/WS_SPP/1.0/?format=csv&labels=both')

df = pd.read_csv(url,
                 usecols=['Reference area', 'Value', 'Unit of measure',
                          'TIME_PERIOD', 'OBS_VALUE'])

That is 35,620 rows. TIME_PERIOD holds strings like '2026-Q1', and a quarter is not a date — so if I want to plot this over time, I have to say which month each quarter begins in. A dictionary is the natural way to write that down:

quarter_start = {'1': 'January', '2': 'April', '3': 'July', '4': 'October'}

df['TIME_PERIOD'].str.get(-1).map(quarter_start)
0    October
1    January
2      April
3       July
4    October
Name: TIME_PERIOD, dtype: str

Now for the actual question: whose housing is getting more expensive fastest, after inflation? The rows I want are the real year-on-year changes for the most recent quarter in the file:

real = (
    df
    .loc[pd.col('Value') == 'Real']
    .loc[pd.col('Unit of measure') == 'Year-on-year changes, in per cent']
    .loc[pd.col('TIME_PERIOD') == '2026-Q1']
    .set_index('Reference area')
    ['OBS_VALUE']
    .nlargest(8)
)
Reference area
Portugal           15.1953
North Macedonia    12.5136
Bulgaria           10.7656
Croatia             9.9606
Spain               9.8684
Slovakia            9.3233
Czechia             8.2802
Estonia             8.1323
Name: OBS_VALUE, dtype: float64

Four decimal places of a percentage is more precision than anyone needs. Here is map with a callable — and note that I am not writing a lambda, because '{:.1f}%'.format is already a function that takes one value and returns a string:

real.map('{:.1f}%'.format)
Reference area
Portugal           15.2%
North Macedonia    12.5%
Bulgaria           10.8%
Croatia            10.0%
Spain               9.9%
Slovakia            9.3%
Czechia             8.3%
Estonia             8.1%
Name: OBS_VALUE, dtype: str

The third form is the one people miss. Pass map a series, and it treats that series as a lookup table, matching your values against the other series' index. So if I build a series of nominal changes indexed by country, I can attach it to the real ones and see how much of each rise is inflation:

nominal = (
    df
    .loc[pd.col('Value') == 'Nominal']
    .loc[pd.col('Unit of measure') == 'Year-on-year changes, in per cent']
    .loc[pd.col('TIME_PERIOD') == '2026-Q1']
    .set_index('Reference area')
    ['OBS_VALUE']
)

(
    real
    .rename('real')
    .reset_index()
    .assign(nominal=pd.col('Reference area').map(nominal),
            gap=lambda df_: df_['nominal'] - df_['real'])
)
    Reference area     real  nominal     gap
0         Portugal  15.1953  17.7828  2.5875
1  North Macedonia  12.5136  16.6452  4.1316
2         Bulgaria  10.7656  14.7728  4.0072
3          Croatia   9.9606  14.3159  4.3553
4            Spain   9.8684  12.8202  2.9518
5         Slovakia   9.3233  13.3893  4.0660
6          Czechia   8.2802  10.0594  1.7792
7          Estonia   8.1323  11.8674  3.7351

The gap column is the part of each rise that inflation accounts for, and it varies more than you might expect: Croatia's headline 14.3 percent shrinks to 10.0 in real terms, while Czechia loses less than two points.

map on a whole data frame

map also exists on data frames, where it applies your function to every single cell. This is how you format a finished table. Take six countries over the last four quarters:

table = (
    df
    .loc[pd.col('Value') == 'Real']
    .loc[pd.col('Unit of measure') == 'Year-on-year changes, in per cent']
    .loc[pd.col('TIME_PERIOD') >= '2025-Q2']
    .loc[pd.col('Reference area').isin(['Portugal', 'Spain', 'United States',
                                        'Germany', 'Japan', 'China'])]
    .pivot_table(index='Reference area', columns='TIME_PERIOD',
                 values='OBS_VALUE')
)

table.map('{:+.1f}%'.format)
TIME_PERIOD    2025-Q2 2025-Q3 2025-Q4 2026-Q1
Reference area
China            -6.4%   -5.3%   -6.3%   -7.1%
Germany          +1.2%   +1.0%   +0.4%   -0.8%
Japan            +0.3%   +1.4%   +2.3%   +nan%
Portugal        +14.7%  +14.7%  +16.3%  +15.2%
Spain           +10.4%   +9.8%   +9.6%   +9.9%
United States    -0.5%   -1.5%   -1.7%   -2.1%

Read the whole thing at a glance: Chinese housing is falling, Portuguese housing is rising by double digits, and Japan has not reported 2026-Q1 yet. That last one produced +nan%, which is na_action earning its keep — add na_action='ignore' and the cell reads NaN again, which is the truth.

If you have been writing Pandas for a few years, you knew this method as applymap. It is worth stating plainly, because most advice online has not caught up: applymap is gone. It was deprecated in Pandas 2.1 and removed in Pandas 3, and calling it now gets you AttributeError: 'DataFrame' object has no attribute 'applymap'. The replacement is DataFrame.map, with the same behavior and a shorter name.

map or apply?

The two look alike and are not interchangeable. map is element-wise on a series: your function receives one value. apply on a data frame is column-wise or row-wise: your function receives an entire series and can look across it. If the answer for each cell depends only on that cell, you want map. If it depends on the other cells in the same row, you want apply with axis='columns'.

Four mistakes people make

Keys missing from your dict silently become NaN. This is the one that quietly destroys data, and it is why map deserves respect. Suppose I want to tidy up two country names:

(
    df['Reference area']
    .map({'Türkiye': 'Turkey', 'Korea': 'South Korea'})
    .value_counts(dropna=False)
)
Reference area
NaN            34556
South Korea      812
Turkey           252
Name: count, dtype: int64

Two names fixed, and 34,556 rows erased. No error, no warning. map is not a find-and-replace; it is a total translation, and anything your table does not cover becomes missing. When you do want a full translation, that silence is a feature — the NaN values tell you which categories you forgot. When you do not, pass a defaultdict to supply a fallback, or use a different method.

Using map where replace is what you meant. For the case above, the right tool is replace, which changes what it recognizes and leaves everything else exactly as it was. Same dictionary, zero collateral damage. My rule: map when every value should be translated, replace when only some should.

Using map where a vectorized operation would do. map runs your Python function once per value, and Python function calls are expensive. On this dataset, df['OBS_VALUE'].map(lambda x: x / 100) takes about 3.5 milliseconds; df['OBS_VALUE'] / 100 takes about 0.02 milliseconds. That is more than a hundredfold, on 35,620 rows, for an expression that is also shorter to read. If arithmetic, a str accessor, or pd.to_datetime can express what you want, use those. Save map for the translations that genuinely need Python.

Forgetting that missing values reach your function. Chain the first mistake into a string function and you get an error rather than bad data, which is the lucky outcome: TypeError: descriptor 'lower' for 'str' objects doesn't apply to a 'float' object. Pass na_action='ignore' and the missing values go through untouched.

Where it shows up in Bamboo Weekly

Thirteen Bamboo Weekly exercises use map on real data. Four worth studying, all of them free to read:

Bamboo Weekly #78: Stock markets has the cleanest dictionary example I know. Trading volumes arrive as strings ending in K, M, or B, so a dict of {'M': 1_000_000, 'K': 1000, 'B': 1_000_000_000} turns that final character into a multiplier: df_['Vol.'].str.get(-1).map(factors).

Bamboo Weekly #77: Paris Olympics maps three-letter Olympic country codes to English names. Note that it calls df.index.map(countries) — indexes have a map method too, and it behaves the same way.

Bamboo Weekly #56: Rent increases ends a long chain with .map(lambda x: f'{x:,.0f}') on a pivot table, putting commas into every rent figure at once. That is DataFrame.map doing what applymap used to do.

Bamboo Weekly #36: Nobel Prize rounds an entire table of percentages with .map(lambda x: round(x, 2)) — a good reminder that when every cell needs the same treatment, one call covers the whole frame.

Practice it

Work through a .map() exercise, with instant feedback and no signup required: practice.lernerpython.com/bamboo-weekly/map/

Go deeper

Two pages pair with this one: apply, for when your function needs a whole row or column, and replace, for when only some values should change. Between the three, most "transform this column" problems have an obvious home.

More Pandas videos on Python and Pandas with Reuven Lerner.

Bamboo Weekly is the practice. If you want the structured version — full courses with downloadable Jupyter notebooks, plus live Pandas office hours when you get stuck — that is LernerPython+Data. A paid Bamboo Weekly subscription is included with it.

See it on real data

Below are the 13 Bamboo Weekly exercises that use map on real-world data — try each one, then study the worked solution.

Part of the Pandas Methods Index. See also practice by skill.