Reduce-side join

MapReduce has no JOIN keyword, but it has something just as good: the shuffle. Map both data sets to the same key, tag where each record came from, and the reducer receives every matching record together. This lesson joins 5 lakh real orders with the menu and reads the counters that show why the reducer must be careful.

Module 9 · MapReduce in depth⏱ 26 minIntermediateReal join on 5 lakh ordersMenu-row order measuredRuns in your browser (Python)8 practice problems
1 The story

Orders know the item, the menu knows the category

The canteen manager wants revenue per category: drinks, snacks, breakfast, meals. The 5 lakh orders in student_orders.csv only say dosa or chai; the category lives in a second file, menu.csv. In SQL this is one JOIN. In MapReduce there is no JOIN keyword, and the two files are read by different map tasks on different workers. Riya has to find a way to bring every order and its menu row to the same place.

MAP: tag each record orders (5 lakh lines) 1,S1413,…,dosa,45 → (dosa, O|1,S1413,…,dosa,45) menu (7 lines) dosa,meal,45,D → (dosa, M|meal) SHUFFLE by item dosa: O, O, M, O, O … menu row may come anywhere REDUCE: join per item 1,S1413,…,dosa,45,meal 459,636 orders joined 40,364 coffee orders: no menu row 24.4 MB shuffled · 24.8 s Make the join key the map output key: the shuffle does the matching, the reducer does the combining. myphatshala.in
Riya

Sir, MapReduce only groups by one key. How do I combine two different files?

Arjun Sir

Make the item the key for both files. The shuffle doesn't care which file a pair came from: every pair with key dosa goes to the same reduce call. You only have to tag each value so the reducer knows which side it came from.

Kabir

And I removed coffee from the menu on purpose. Let's see what a join does with orders that have no menu row.

Arjun Sir

Good. We will also catch something that surprises most people: the menu row does not always arrive first. Our counters will prove it.

2 Definition

Reduce-side join

Formal definition (write this in exams)

A reduce-side join (also called a repartition join) combines two or more data sets on a common key inside the reduce phase. Each input is read by its own mapper (configured with MultipleInputs), which emits the join key as the map output key and the record, tagged with its source, as the value. The shuffle partitions and groups all pairs by the join key, so a single reduce call receives every record of every data set that shares that key. The reducer separates the values by tag, typically buffers one side in memory, and emits the combined records according to the join type (inner, left outer, right outer or full outer). It works for inputs of any size and needs no pre-sorting or co-partitioning, at the cost of shuffling all records of all inputs across the network.

In simple words

Both files are mapped to (join key, labelled record). Hadoop's shuffle brings everything with the same key to one reducer, and the reducer glues the matching records together.

Words you'll meet in this lesson

Join keyThe column both data sets share, here item.
TagA marker on each value saying where it came from: O| order, M| menu.
MultipleInputsLets one job read several paths, each with its own InputFormat and Mapper.
Inner joinOnly keys present on both sides are output.
Left outer joinAll records of the left side, with blanks where the right side is missing.
Repartition joinAnother name for a reduce-side join: the data is repartitioned by the join key.
BufferingKeeping one side's records in a list until the other side has been seen.
Join skewOne key with far more records than others: one reducer does most of the work.

From our lab (Hadoop 3.5.0, 3 workers, 5 lakh orders + 7-line menu)

Inputsstudent_orders.csv (3 splits) + menu_nocoffee.csv (1 split) = 4 map tasks, 500,007 map input records
Shuffle500,007 records, 24,448,645 bytes, 8 reduce groups (one per item, coffee included)
Menu row positionMENU_FIRST=3, MENU_NOT_FIRST=4: for 4 of 7 items the menu row arrived after some orders
ResultORDERS_JOINED=459,636, ORDERS_WITHOUT_MENU=40,364 (every coffee order); 20,851,062 bytes written; 24.8 s

Joins at three levels

Pick your level. New to this? Start at Beginner and climb.

Beginner Matching name tags

  • Two lists: the bill book (who ordered what) and the price board (which item is which category).
  • Every slip from both lists gets a coloured sticker (the tag) and is dropped into a box labelled with the item name (the key).
  • One person opens the dosa box, finds the one price-board slip and writes “meal” on every order slip in that box.
  • A box with order slips but no price-board slip (coffee) is the interesting case: throw them away (inner join) or keep them with a blank (outer join).

Intermediate How the job is wired

  • MultipleInputs.addInputPath(job, orders, TextInputFormat.class, OrderMapper.class) and the same for the menu with MenuMapper. No setMapperClass.
  • Both mappers must emit the same key and value types: (Text item, Text tagged).
  • The reducer loops over the values once: menu values set category, order values go into a list. After the loop it writes each order with the category.
  • Values arrive in no guaranteed order. In our run the menu row came first for only 3 of 7 items, so the order list is not optional.

Advanced Costs and refinements

  • Everything is shuffled: all 5 lakh orders crossed the network just to meet 7 menu rows. That is the price of a reduce-side join.
  • Memory: the reducer buffers one side. Buffer the smaller side per key; here that would be the menu, but you can only do that if the menu arrives first.
  • Secondary sort fixes the order: put the tag in a composite key (item, tag), sort the menu tag before the order tag, and partition and group by item only. Then the menu row is always first and orders can be streamed without a list (Lesson 9.7).
  • Skew: chai has 149,669 orders, thali 30,292. One reducer gets the chai group whatever you do; salting the key or a map-side join (Lesson 9.6) avoids that.

Exam tip: For “Explain reduce-side join”, write: both mappers emit (join key, tagged record); the shuffle groups by join key; the reducer separates by tag, buffers one side and emits combinations. Add one drawback (all data is shuffled; buffering can run out of memory) and when to use it (both data sets are large).

3 Types and ways to use it

Join types in the reducer

The map side is the same for every join type. Only the last few lines of the reducer change.

Join typeReducer emitsCoffee (orders, no menu row)Menu item never ordered
Innerorders × menu, only when both existdropped (our job: 40,364)dropped
Left outer (orders left)every order, category or blankkept, category emptydropped
Right outer (menu right)every menu row, with its orders or blanksdroppedkept with no order
Full outereverything from both sideskeptkept
Anti joinorders whose key has no menu rowthe only outputdropped

The three ways to join in MapReduce

Reduce-side join

Any sizes, any layout. Shuffles everything. This lesson.

general

Map-side join (broadcast)

One side is small enough for every mapper's memory; ship it with the distributed cache. No shuffle at all.

Lesson 9.6

Sorted, partitioned join

Both inputs already sorted and split the same way by the key (CompositeInputFormat); mappers merge them directly.

special case

Hive and Spark pick between the same strategies: a “shuffle join” (Spark's sort-merge join) is a reduce-side join, a “broadcast join” or Hive “map join” is the map-side join of Lesson 9.6.

4 Step by step

Joining 5 lakh orders with the menu

Run on the Module 8 lab cluster with join_lab.sh (in “Run it on your PC”). The data comes from make_student_orders.py.

  1. Prepare a menu with a missing item

    grep -v coffee menu.csv > menu_nocoffee.csv
    hdfs dfs -put -f menu_nocoffee.csv /user/hadoop/data/
    chai,drink,15,A samosa,snack,18,B vada pav,snack,20,B poha,breakfast,25,C idli,breakfast,30,C dosa,meal,45,D thali,meal,80,D
    Real menus go out of date. Removing coffee lets us see what an inner join silently does with orders that have no match.
  2. Two mappers, one key

    // orders: (item, "O|whole line")
    item.set(line.toString().split(",")[3]);
    val.set("O|" + line);
    // menu:   (item, "M|category")
    item.set(f[0]); val.set("M|" + f[1]);
    
    MultipleInputs.addInputPath(job, orders, TextInputFormat.class, OrderMapper.class);
    MultipleInputs.addInputPath(job, menu,   TextInputFormat.class, MenuMapper.class);
    Each input path gets its own mapper class. The tag is just a prefix on a Text value; production code often uses a small custom Writable (Lesson 9.3) instead.
  3. Run the job

    hadoop jar mr9.jar JoinReduceSide /user/hadoop/data/student_orders.csv \
        /user/hadoop/data/menu_nocoffee.csv /out/JoinReduceSide
    JobSubmitter:203 - number of splits:4 Launched map tasks=4 Launched reduce tasks=1 Map input records=500007 Map output records=500007 Reduce input groups=8 Reduce shuffle bytes=24448645
    Three splits of orders plus one split of menu: 4 map tasks. Every record was emitted and shuffled, 24.4 MB of it, to bring 7 menu rows next to 5 lakh orders. 8 groups: 7 menu items plus coffee.
  4. Proof that the reducer must buffer

    MENU_FIRST=3
    MENU_NOT_FIRST=4
    The reducer counted whether the menu row was the first value of its group. For 4 of 7 items it was not, because values from 4 map tasks are merged in no particular order. Code that assumed “menu comes first” would have lost thousands of orders without any error.
  5. Matched and unmatched

    ORDERS_JOINED=459636
    ORDERS_WITHOUT_MENU=40364
    Bytes Written=20851062
    459,636 + 40,364 = 500,000. The 40,364 are exactly the coffee orders: an inner join dropped them, and only the counter tells you. Always count unmatched records.
  6. The joined rows

    hdfs dfs -cat /out/JoinReduceSide/part-r-00000 | head -2
    1,S1413,2026-09-14 15:30,dosa,45,meal 2,S0973,2026-09-26 20:11,samosa,18,snack
    Each order now carries its category. The next lesson produces exactly the same 459,636 rows with a map-side join in 18.9 s and no shuffle at all.

If a single key has millions of records on both sides (for example a NULL or default join key like “unknown”), the reducer's list can exhaust the task's heap and the job fails with an OutOfMemoryError. Filter such keys out, buffer only the small side, or use secondary sort to stream.

5 See it happen

One item's journey through the join

Six steps for the item dosa, and what happens to coffee.

Tag, shuffle, buffer, join 6 steps
What you see hadoop@namenode : /tmp/mr9 $ hadoop jar mr9.jar JoinReduceSide …number of splits:4(3 order splits + 1 menu split) Map input records=500007Map output records=500007 Reduce shuffle bytes=24448645Reduce input groups=8 MENU_FIRST=3MENU_NOT_FIRST=4 ORDERS_JOINED=459636ORDERS_WITHOUT_MENU=40364 $ hdfs dfs -cat …/part-r-00000 | head -21,S1413,2026-09-14 15:30,dosa,45,meal2,S0973,2026-09-26 20:11,samosa,18,snack myphatshala.in
Behind the screen OrderMapper ×3orders MenuMapper ×1menu reduce("dosa", values) MultipleInputs: each path gets its own mapper class (dosa, O|1,…)(dosa, M|meal)same key type, tagged values O O O O …MO O …shuffle: all dosa pairs, from 4 map tasks, in one group O O O O …M ← not first!orders buffered in a listthe reducer can't write until it has seen the menu row dosa → meal: 49,927 rows outcoffee: no M row → 40,364 dropped part-r-00000 · 459,636 joined rows · 20.9 MB myphatshala.in
6 Real-world use

Reduce-side joins at work

txnsaccts

1. Transactions with customer accounts

A bank joins a day's card transactions (hundreds of millions) with the accounts table (crores of rows) on account number. Both sides are too large for memory, so the join is shuffled.

Transactions + accounts→key = account no.→Enriched transactions

2. Clickstream with user profiles

E-commerce sites join page-view logs with user profiles to attribute views to segments (city, device, plan). Hive's default “common join” for two big tables is exactly this pattern.

Logs + users→key = user_id→Views per segment
unmatched40,364

3. Data quality checks

An anti join (records with no match) finds orphans: orders for items missing from the master list, payments for closed accounts. The unmatched count is reported every night.

Facts + master data→anti join→Orphan report
SQLshuffle

4. Inside SQL engines

A JOIN between two large tables in Hive or Spark SQL becomes a shuffle join: both sides repartitioned by the join key, then joined per partition. Reading “Exchange hashpartitioning” in a Spark plan means this lesson.

JOIN … ON→shuffle both sides→Joined table

Simplified; organisations differ in tools and policies.

7 Run the code

Simulate a reduce-side join

Two small inputs, tagged map output, a shuffle that mixes the order of values (as Hadoop does), and a reducer that joins. Change JOIN to "left" to keep orders without a menu row.

import random
random.seed(4)
orders = ["1,S1413,dosa,45", "2,S0973,samosa,18", "3,S0007,coffee,20", "4,S0001,dosa,45", "5,S0002,chai,15"]
menu = ["chai,drink", "samosa,snack", "dosa,meal", "thali,meal"]
JOIN = "inner"

pairs = [(o.split(",")[2], "O|" + o) for o in orders]          # OrderMapper
pairs += [(m.split(",")[0], "M|" + m.split(",")[1]) for m in menu]  # MenuMapper

groups = {}
for k, v in pairs:                                             # shuffle: group by item
    groups.setdefault(k, []).append(v)
for k in groups:
    random.shuffle(groups[k])                                  # values arrive in no fixed order

for item in sorted(groups):                                    # reducer
    category, buffered = None, []
    for v in groups[item]:
        if v.startswith("M|"):
            category = v[2:]
        else:
            buffered.append(v[2:])
    for o in buffered:
        if category is not None:
            print(o + "," + category)
        elif JOIN == "left":
            print(o + ",")                                     # no menu row: empty category
5,S0002,chai,15,drink 4,S0001,dosa,45,meal 1,S1413,dosa,45,meal 2,S0973,samosa,18,snack
  • pairs = [(o.split(",")[2], "O|" + o) for o in orders] # OrderMapperThe join key (item) becomes the map output key; the tag O| says “this came from orders”.
  • random.shuffle(groups[k]) # values arrive in no fixed orderHadoop merges values from several map tasks; in our lab the menu row was first in only 3 of 7 groups.
  • buffered.append(v[2:])Orders wait in a list until the category is known. This list is the memory risk of reduce-side joins.

Run it on your PC: join orders with the menu

Use the Module 8 lab cluster (Lesson 8.1). Create the data with make_student_orders.py (it writes student_orders.csv and menu.csv), copy the files and JoinReduceSide.java into /tmp/mr9 in the namenode container, compile into mr9.jar and run join_lab.sh. The script also runs the map-side join of Lesson 9.6 for comparison.

Reduce-side join

// JoinReduceSide.java: reduce-side join of orders with the menu.
// Both inputs are mapped to (item, tagged record); the reducer meets them per item and writes
// every order with its category added.
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.NullWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.MultipleInputs;
import org.apache.hadoop.mapreduce.lib.input.TextInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.util.GenericOptionsParser;

public class JoinReduceSide {
  enum Join { ORDERS_JOINED, ORDERS_WITHOUT_MENU, MENU_FIRST, MENU_NOT_FIRST }

  // orders: order_id,student_id,time,item,amount   ->  (item, "O|whole line")
  public static class OrderMapper extends Mapper<LongWritable, Text, Text, Text> {
    private final Text item = new Text(), val = new Text();
    @Override
    protected void map(LongWritable k, Text line, Context ctx) throws IOException, InterruptedException {
      item.set(line.toString().split(",")[3]);
      val.set("O|" + line);
      ctx.write(item, val);
    }
  }

  // menu: item,category,price,counter   ->  (item, "M|category")
  public static class MenuMapper extends Mapper<LongWritable, Text, Text, Text> {
    private final Text item = new Text(), val = new Text();
    @Override
    protected void map(LongWritable k, Text line, Context ctx) throws IOException, InterruptedException {
      String[] f = line.toString().split(",");
      item.set(f[0]); val.set("M|" + f[1]);
      ctx.write(item, val);
    }
  }

  // For one item: one menu record and thousands of order records, in NO particular order
  public static class JoinReducer extends Reducer<Text, Text, Text, NullWritable> {
    private final Text out = new Text();
    @Override
    protected void reduce(Text item, Iterable<Text> vals, Context ctx) throws IOException, InterruptedException {
      String category = null;
      List<String> orders = new ArrayList<>();         // must buffer: the menu record may arrive last
      boolean first = true;
      for (Text v : vals) {
        String s = v.toString();
        if (s.startsWith("M|")) {
          category = s.substring(2);
          ctx.getCounter(first ? Join.MENU_FIRST : Join.MENU_NOT_FIRST).increment(1);
        } else orders.add(s.substring(2));
        first = false;
      }
      if (category == null) { ctx.getCounter(Join.ORDERS_WITHOUT_MENU).increment(orders.size()); return; }
      for (String o : orders) { out.set(o + "," + category); ctx.write(out, NullWritable.get()); }
      ctx.getCounter(Join.ORDERS_JOINED).increment(orders.size());
    }
  }

  public static void main(String[] args) throws Exception {
    Configuration conf = new Configuration();
    String[] rest = new GenericOptionsParser(conf, args).getRemainingArgs();   // orders menu out
    Job job = Job.getInstance(conf, "reduce-side join");
    job.setJarByClass(JoinReduceSide.class);
    MultipleInputs.addInputPath(job, new Path(rest[0]), TextInputFormat.class, OrderMapper.class);
    MultipleInputs.addInputPath(job, new Path(rest[1]), TextInputFormat.class, MenuMapper.class);
    job.setReducerClass(JoinReducer.class);
    job.setMapOutputKeyClass(Text.class);
    job.setMapOutputValueClass(Text.class);
    job.setOutputKeyClass(Text.class);
    job.setOutputValueClass(NullWritable.class);
    FileOutputFormat.setOutputPath(job, new Path(rest[2]));
    System.exit(job.waitForCompletion(true) ? 0 : 1);
  }
}

// In the mr-lab folder:
//   python3 make_student_orders.py
//   docker exec lab8-namenode-1 mkdir -p /tmp/mr9
//   docker cp JoinReduceSide.java lab8-namenode-1:/tmp/mr9/
//   docker cp student_orders.csv lab8-namenode-1:/tmp/mr9/
//   docker cp menu.csv lab8-namenode-1:/tmp/mr9/
// Inside the container, in /tmp/mr9:
//   hdfs dfs -D dfs.blocksize=8388608 -put -f student_orders.csv /user/hadoop/data/
//   mkdir -p classes && javac -cp $(hadoop classpath) -d classes *.java && jar cf mr9.jar -C classes .
(compiles to mr9.jar; run it with join_lab.sh below)

The 8 MB block size gives 3 splits for the orders, so you can see values from several map tasks being merged.

Join lab: reduce-side vs map-side

# join_lab.sh: join 5 lakh orders with the 8-line menu, reduce-side and map-side, and compare
cd /tmp/mr9
O=/user/hadoop/data/student_orders.csv; MENU=/user/hadoop/data/menu.csv
hdfs dfs -put -f menu.csv $MENU
echo "== menu.csv (minus 'coffee', to show an order without a menu row)"
grep -v coffee menu.csv > menu_nocoffee.csv; hdfs dfs -put -f menu_nocoffee.csv /user/hadoop/data/
cat menu_nocoffee.csv
for j in JoinReduceSide JoinMapSide; do
  hdfs dfs -rm -r -f -skipTrash /out/$j > /dev/null 2>&1
  start=$(date +%s%N)
  hadoop jar mr9.jar $j $O /user/hadoop/data/menu_nocoffee.csv /out/$j > /tmp/$j.log 2>&1
  echo "== $j   $(( ($(date +%s%N) - start) / 1000000 )) ms"
  grep -E "number of splits|Launched (map|reduce) tasks|Map input records|Map output records|Reduce shuffle bytes|Reduce input groups|ORDERS_JOINED|ORDERS_WITHOUT_MENU|MENU_(NOT_)?FIRST|Bytes Written" /tmp/$j.log | sed -E 's/^[0-9-]+ [0-9:]+ INFO  //; s/^\s*/  /'
  hdfs dfs -ls /out/$j | grep part | awk '{print "  " $5, $8}'
done
echo "== a joined row"
hdfs dfs -cat /out/JoinMapSide/part-m-00000 2>/dev/null | head -2
echo "== same rows from both joins?"
diff <(hdfs dfs -cat /out/JoinReduceSide/part-* | sort) <(hdfs dfs -cat /out/JoinMapSide/part-* | sort) > /dev/null && echo "identical (459,636 rows each, compared after sorting)"
echo "== map task log of JoinMapSide: the cached menu"
app=$(grep -o "application_[0-9_]*" /tmp/JoinMapSide.log | head -1); sleep 5
yarn logs -applicationId $app -log_files stderr 2>/dev/null | grep -E "cache files|menu loaded" | head -2 | cut -c1-200

# Inside the namenode container:  bash /tmp/mr9/join_lab.sh
== menu.csv (minus 'coffee', to show an order without a menu row) chai,drink,15,A samosa,snack,18,B vada pav,snack,20,B poha,breakfast,25,C idli,breakfast,30,C dosa,meal,45,D thali,meal,80,D == JoinReduceSide 24828 ms JobSubmitter:203 - number of splits:4 Launched map tasks=4 Launched reduce tasks=1 Map input records=500007 Map output records=500007 Reduce input groups=8 Reduce shuffle bytes=24448645 MENU_FIRST=3 MENU_NOT_FIRST=4 ORDERS_JOINED=459636 ORDERS_WITHOUT_MENU=40364 Bytes Written=20851062 20851062 /out/JoinReduceSide/part-r-00000 == JoinMapSide 18943 ms JobSubmitter:203 - number of splits:3 Launched map tasks=3 Map input records=500000 Map output records=459636 ORDERS_JOINED=459636 ORDERS_WITHOUT_MENU=40364 Bytes Written=20851062 9020852 /out/JoinMapSide/part-m-00000 8993924 /out/JoinMapSide/part-m-00001 2836286 /out/JoinMapSide/part-m-00002 == a joined row 1,S1413,2026-09-14 15:30,dosa,45,meal 2,S0973,2026-09-26 20:11,samosa,18,snack == same rows from both joins? identical (459,636 rows each, compared after sorting) == map task log of JoinMapSide: the cached menu cache files: [/user/hadoop/data/menu_nocoffee.csv#menu] menu loaded in setup(): 7 items, working dir = /tmp/hadoop-hadoop/nm-local-dir/usercache/hadoop/appcache/application_1792643020992_0040/container_1792643020992_0040_01_000003/.

Real output. MENU_FIRST/MENU_NOT_FIRST will differ between runs because the merge order depends on which map task finishes first; the joined rows never do. The JoinMapSide part is explained in Lesson 9.6.

8 Line by line

The reducer for one item

The values for dosa arrive with the menu row in the middle. Watch the list grow until the category is known.

Worked examples: real problems, solved and traced in memory

Three reduce-side join problems: the shuffle cost, the memory risk, and choosing the join type.

Example 1BeginnerDesign · tags

Join students with their hostel

Problem statement

orders.csv: order_id,student_id,item,amount. students.csv: student_id,name,hostel. The warden wants revenue per hostel. What is the join key, what do the two mappers emit, and what does the reducer write?

Input
3 orders · 2 students
Expected output
S1 Ganga [45, 80]
S2 Yamuna [15]

Approach

  1. Join key = student_id, in both files.
  2. Order mapper: (student_id, "O|amount"); student mapper: (student_id, "S|hostel").
  3. Reducer: find the hostel, write (hostel, amount) for each order; a second job (or step) sums per hostel.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

Example 2IntermediateSizing · shuffle

What does a reduce-side join cost?

Problem statement

Join 200 GB of orders with a 50 MB menu using a reduce-side join on a 20-node cluster where each node can receive shuffle data at 100 MB/s. Roughly how long does the shuffle alone take, and how much of it is the menu?

Input
200 GB + 50 MB · 20 × 100 MB/s
Expected output
shuffle ≈ 1.7 min; menu is 0.024% of it

Approach

  1. Everything is shuffled: both inputs.
  2. Total bandwidth = nodes × per-node rate.
  3. Compare with the menu size.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

Example 3AdvancedMemory · skew

Will the reducer run out of memory?

Problem statement

A reducer buffers all orders of one item as Java Strings of about 40 characters (≈ 120 bytes each in memory). chai has 149,669 orders in our data; a real chain has 4 crore chai orders a month. The reduce container has a 300 MB heap. Does each case fit?

Input
149,669 vs 4 crore · 120 B · 300 MB heap
Expected output
our lab   :      17 MB -> fits
real chain:    4578 MB -> OutOfMemoryError

Approach

  1. Memory = records × bytes per record.
  2. Compare with the heap.
  3. Decide: buffer the small side instead, or stream with secondary sort.

Solution: run it live

What happens in memory, step by step

Recorded from a real Python run: press Next to execute one line at a time. Watch variables change in each frame, and lists and dictionaries (numbered #1, #2 …) fill up as objects.

9 Quiz and revision

Check yourself

Quick revision

Lesson 9.5 in one screen

  • Reduce-side join: both mappers emit (join key, tagged record); the shuffle groups by key; the reducer separates by tag and combines.
  • MultipleInputs.addInputPath(job, path, InputFormat, Mapper) for each input.
  • Values arrive in no fixed order: lab counters MENU_FIRST=3, MENU_NOT_FIRST=4. Buffer one side, or use secondary sort to put the small side first.
  • Lab: 500,007 records and 24.4 MB shuffled, 8 groups, 459,636 joined, 40,364 coffee orders dropped, 24.8 s.
  • Join types: inner, left outer, right outer, full outer, anti; only the reducer's output rule changes.
  • Costs: everything is shuffled; buffering a hot key (chai 149,669 orders) can exhaust memory.
  • Use when both sides are big; if one side is small, use a map-side join.

Interview and viva questions

What is a reduce-side join in Hadoop?

A join performed in the reduce phase. Each input has a mapper that emits the join key as the map output key and the record, tagged with its source, as the value. The shuffle sends all records with the same key to one reduce call, where the reducer separates them by tag and emits the joined records. It handles inputs of any size but shuffles all data.

What is the difference between a map-side join and a reduce-side join?

A reduce-side join shuffles both data sets by the join key and joins in the reducer; it works for any sizes. A map-side join loads the small data set into every mapper's memory (via the distributed cache) and joins while reading the large one, with no shuffle; it needs one side small enough to fit in memory. In our lab both produced the same 459,636 rows; the map-side join took 18.9 s instead of 24.8 s.

Why do reduce-side joins need buffering?

The values for a key come from several map tasks and are merged in no guaranteed order, so the reducer may see many records from one side before the matching record from the other side. It must keep them in memory until the loop is complete, unless secondary sort is used to make one side arrive first.

How do you do an outer join in MapReduce?

The map side is unchanged. In the reducer, after separating the values by tag, emit unmatched records too: for a left outer join, emit every left-side record with empty fields if the right side is missing; for a right outer join, the reverse; for a full outer join, both.

How do you handle skew in a reduce-side join?

A key with very many records sends them all to one reducer, which becomes slow or runs out of memory. Options: filter out junk keys like NULL, buffer only the smaller side and stream the larger with secondary sort, split (salt) the hot key across several reducers while replicating the small side, or use a map-side join when one side is small.

Practice problems

Reduce-side join exercises using the real numbers from the lab.

P1Easy

Check the counters

ORDERS_JOINED=459,636 and ORDERS_WITHOUT_MENU=40,364. Do they add up to all orders?

Input
two counters
Expected output
True
Show solution and explanation
print(459636 + 40364 == 500000)
True

How it works: True: every order was either joined or counted as unmatched; none was lost silently.

P2Easy

Where do 500,007 records come from?

Map input records were 500,007. Explain the 7.

Input
orders + menu
Expected output
500007
Show solution and explanation
print(500000 + 7)
500007

How it works: 5 lakh orders plus the 7 rows of menu_nocoffee.csv; both inputs go through map and shuffle.

P3Easy

Bytes per shuffled record

24,448,645 shuffle bytes for 500,007 records. What is the average size of one tagged record?

Input
bytes / records
Expected output
48.9 bytes
Show solution and explanation
print(round(24448645 / 500007, 1), "bytes")
48.9 bytes

How it works: About 49 bytes: the whole order line plus the “O|” tag and the item key, plus framing.

P4Medium

Full outer join

Write the reducer output for a full outer join: orders without a menu row get an empty category, menu items without orders get one line “,,item,category”.

Input
3 groups
Expected output
3,coffee,
1,dosa,meal
,,thali,meal
Show solution and explanation
groups = {"coffee": ["O|3,coffee"], "dosa": ["O|1,dosa", "M|meal"], "thali": ["M|meal"]}
for item, vals in sorted(groups.items()):
    cat = next((v[2:] for v in vals if v.startswith("M|")), None)
    ords = [v[2:] for v in vals if v.startswith("O|")]
    for o in ords:
        print(o + "," + (cat or ""))
    if not ords:
        print(",," + item + "," + cat)
3,coffee, 1,dosa,meal ,,thali,meal

How it works: coffee kept with a blank, dosa joined, thali printed once with no order.

P5Medium

Menu first with a composite key

Sort the values by a tag rank so the menu row (rank 0) always comes before orders (rank 1), then stream without a list.

Input
mixed values
Expected output
1,dosa,45,meal
4,dosa,45,meal
Show solution and explanation
values = ["O|1,dosa,45", "M|meal", "O|4,dosa,45"]
values.sort(key=lambda v: 0 if v.startswith("M|") else 1)
category = None
for v in values:
    if v.startswith("M|"):
        category = v[2:]
    else:
        print(v[2:] + "," + category)
1,dosa,45,meal 4,dosa,45,meal

How it works: In Hadoop the sort happens in the shuffle via a composite key (item, tag) with grouping on item only: Lesson 9.7.

P6Medium

Reducer load

With one reducer per item, which reducer has the most work? Use the real order counts.

Input
orders per item
Expected output
chai 149669 = 30% of all orders
Show solution and explanation
counts = {"chai": 149669, "coffee": 40364, "samosa": 69884, "vada pav": 69754, "poha": 45336, "idli": 44774, "dosa": 49927, "thali": 30292}
top = max(counts, key=counts.get)
print(top, counts[top], f"= {counts[top] / sum(counts.values()) * 100:.0f}% of all orders")
chai 149669 = 30% of all orders

How it works: chai: 30% of all records go to one reduce call. That is join skew.

P7Business problem

Unmatched means lost money

The finance report used an inner join and missed every coffee order (40,364 orders at ₹20). How much revenue was missing from the report?

Input
40,364 × ₹20
Expected output
Rs 807,280
Show solution and explanation
print(f"Rs {40364 * 20:,}")
Rs 807,280

How it works: ₹8,07,280 missing for one month. Count unmatched records, or use a left outer join, in every financial join.

P8Advanced

Salting a hot key

Split chai into 3 reducers by appending a salt to the key on the order side, and replicate the menu row to all 3 salts. Show that every chai order still gets its category.

Input
5 chai orders
Expected output
chai#0 ['1,chai,15,drink', '4,chai,15,drink']
chai#1 ['2,chai,15,drink', '5,chai,15,drink']
chai#2 ['3,chai,15,drink']
Show solution and explanation
orders = [f"O|{i},chai,15" for i in range(1, 6)]
groups = {}
for n, o in enumerate(orders):
    groups.setdefault(f"chai#{n % 3}", []).append(o)            # salt orders
for s in range(3):
    groups.setdefault(f"chai#{s}", []).append("M|drink")       # replicate menu row
for key in sorted(groups):
    cat = next(v[2:] for v in groups[key] if v.startswith("M|"))
    print(key, [v[2:] + "," + cat for v in groups[key] if v.startswith("O|")])
chai#0 ['1,chai,15,drink', '4,chai,15,drink'] chai#1 ['2,chai,15,drink', '5,chai,15,drink'] chai#2 ['3,chai,15,drink']

How it works: Three smaller groups instead of one; the small side is copied to every salt. Spark's skew-join handling does the same.

Real-world scenario questions (asked in company interviews)

Joins are among the most asked MapReduce and Spark interview topics: how they work, what they cost and what goes wrong.

Concepts · joinsFresher

“How do you join two datasets in MapReduce?”

Asked right after word count.

The interviewer is checking: key choice, tagging and the shuffle.

Show a strong answer
  • Reduce-side: both mappers emit (join key, tagged record) via MultipleInputs; the shuffle groups by key; the reducer separates by tag and combines.
  • Map-side: if one side is small, load it in every mapper from the distributed cache and join while reading; no shuffle.
  • Mention the trade-off: reduce-side works for any sizes but shuffles everything.
Debugging · dataExperienced

The joined output has fewer rows than the orders table

Finance says revenue in the joined report is lower than in the raw orders.

The interviewer is checking: inner join semantics and counters.

Show a strong answer
  • An inner join drops records without a match: check a counter for unmatched keys (our lab: 40,364 coffee orders, no menu row).
  • Fix the master data, or switch to a left outer join so unmatched rows appear with blanks.
  • Add a reconciliation check: joined + unmatched = input.
Internals · orderingExperienced

“My join reducer works in testing but loses rows in production”

The reducer writes orders as soon as it has the category, and assumes the dimension row comes first.

The interviewer is checking: value order is not guaranteed.

Show a strong answer
  • Values for a key are merged from many map tasks in no fixed order; with one map task in testing it looked ordered.
  • Buffer one side until the loop ends, or make the order deterministic with secondary sort: composite key (join key, tag), partition and group on the join key.
  • Add a counter for “dimension not first” to detect it.
Performance · skewExperienced

One reducer of a join runs for hours; the other 199 finish in minutes

Joining page views with users; one user_id is the anonymous value “0”.

The interviewer is checking: skew diagnosis and fixes.

Show a strong answer
  • Check reduce input records per task: one key holds most records.
  • Filter or handle the junk key separately; salt hot keys and replicate the small side; or use a map-side join if the users table fits in memory.
  • In Spark, adaptive query execution can split skewed partitions automatically.