EDA and Feature Engineeringpandas 3.0 · scikit-learn 1.9 · seaborn 0.13 · Python 3.12+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
72 small wins to finish your pathNext lesson →

Merging a lookup table

A lookup table is a small second table that maps a code to its meaning, and DataFrame.merge is the call that attaches it to the main table by matching on a shared column.

Last updated: 12 Oct, 2026 · pandas 3.0 · scikit-learn 1.9

The missing value map audited the holes in the Zomato table. One of its columns, Country Code, holds numbers like 1, 30 and 216 that mean nothing on their own. The archive ships a second file that translates them, and this lesson joins the two.

Syntax: merge takes the other frame, the column to match on, and the rule for which rows to keep.

python
left.merge(right,
           on='Country Code',   # a column with this name must exist in both frames
           how='left')          # keep every row of left; 'inner', 'right' and 'outer' differ

Reading the lookup out of the archive

The lookup file, and merging it in · from the EDA and Feature Engineering video · 12:56 to 15:52

The lookup is an Excel file, not a CSV, so read_excel reads it. It opens from the zip the same way, and openpyxl does the decoding, so there is no encoding argument to choose this time.

ExampleFifteen country codes and their names
with zf.open('Country-Code.xlsx') as fh:
    codes = pd.read_excel(fh)

print(codes.shape)
print(codes.head(8).to_string())
  • 15 rows and 2 columns. A lookup is small by nature: one row per code.
  • Country Code is in both frames, with the same name and the same int64 type. That is what makes the join possible.
  • The names come from the source file as it is. Row 6 spells the Philippines with two l's and one p, and the right place to correct a value like that is after the merge, with a replace on the new column.

Attaching the names

merge matches every row of the left frame against the lookup on the key column, and adds the lookup's other columns to it. how='left' means every row of the left frame survives, whether or not the lookup had a match for it.

Example9551 rows, one column wider
final_df = df.merge(codes, on='Country Code', how='left')

print("before:", df.shape, " after:", final_df.shape)
print("rows with no country name:", final_df['Country'].isnull().sum())
print(final_df['Country'].value_counts().head(3).to_string())
A left merge. The zomato frame of 9551 rows and 21 columns has a Country Code column, and the Country-Code lookup of 15 rows maps each code to a country name. Calling merge with on='Country Code' and how='left' returns 9551 rows and 22 columns, the extra column being Country. how='left' keeps every zomato row, how='inner' keeps only rows whose code is in both frames, how='right' gives one row per lookup match, and how='outer' keeps every row from both sides.

What the merge changed

  • 21 columns became 22. The key column is not duplicated; only Country is added.
  • 9551 rows became 9551 rows. That is the result to check for. A left merge preserves the row count only when each key matches at most one lookup row.
  • No row ended with an empty Country. Every code in the table was present in the lookup. A non-zero count here would mean codes the lookup does not cover, and the left merge keeps those rows with NaN rather than dropping them.
  • 8652 of the 9551 restaurants are in India. A fact that was unreadable as the number 1 and is obvious as a name. Making a column readable is the whole purpose of the join.

Choosing how the rows are matched

how decides which rows survive when the two sides do not line up. To see the difference, here is the same merge against a lookup trimmed to three of its fifteen codes.

ExampleThe same join under four values of how
three = codes.head(3)                      # a lookup that covers only three codes
print("codes in the lookup:", three['Country Code'].tolist())

for how in ['left', 'inner', 'right', 'outer']:
    merged = df.merge(three, on='Country Code', how=how)
    print(f"how={how:7} {merged.shape[0]:5d} rows  "
          f"{merged['Country'].isnull().sum():5d} with no country")
  • 'left' keeps all 9551 rows and leaves 815 of them with no country name, because their code is not in the trimmed lookup. This is what you want when the lookup is extra information and the main table is the data.
  • 'inner' keeps 8736 rows, the ones whose code the lookup covers. The other 815 are gone, and nothing says so. An inner join is the quietest way to lose rows in pandas.
  • 'right' keeps the same 8736 here, because each of the three codes matches at least one restaurant. A right join returns one row per match on the right side, so a lookup row with no match would appear once with empty restaurant columns.
  • 'outer' keeps 8736 plus the 815 unmatched, which is all 9551 again, and would also keep any lookup row that matched nothing.

left vs inner vs right vs outer

howRows keptRow count hereUse it when
'left'Every row of the left frame9551The lookup adds information to data you must not lose
'inner'Only rows matched on both sides8736A row without a match is useless to you
'right'Every row of the right frame, matched8736The right frame is the one driving the result
'outer'Every row of both sides9551You are reconciling two sources and want to see what is in one and not the other

Where a lookup merge is used

  • A coded column. Country codes, product codes, store ids and status flags are stored as numbers and mean nothing until a lookup is attached.
  • Adding a feature from another table. Population per city, average income per postcode, a category per product: each is a merge on a key.
  • Checking a key. Merging and then counting the empty cells in the new column is the quickest test of whether two tables agree on their keys.
Watch out. Compare shape before and after every merge. If the right frame has more than one row per key, the merge multiplies rows instead of adding a column, and a table of 9551 can come back as 40000 with no warning. validate='many_to_one' makes pandas raise instead.
Try it yourself
  • Add validate='many_to_one' to the working merge. It passes. Then duplicate a row of codes with pd.concat([codes, codes.head(1)]) and watch it raise.
  • Pass indicator=True and print merged['_merge'].value_counts() to see which side each row came from.
  • Correct the misspelled country name on the merged frame with final_df['Country'] = final_df['Country'].replace({'Phillipines': 'Philippines'}) and count the rows it changed.

This is what real progress feels like.