Give every worker a copy of the menu card
The reduce-side join from Lesson 9.5 worked, but Riya keeps looking at one counter: 24,448,645 bytes shuffled. Five lakh orders crossed the network just so each could meet one line of a menu that has seven lines. Kabir laughs: “It's like sending every customer to the head office to ask what category a samosa is. Just print the menu card and stick it on every counter.”
When one side of a join is small, don't move the big side to meet it. Move the small side to every map task instead. Hadoop has a built-in delivery service for exactly that: the distributed cache.
So each map task loads the menu into memory once, and then looks up every order as it reads it?
Exactly. A map-only job: no shuffle, no sort, no reducer. Today we ran it on the same 5 lakh orders and got the same 459,636 joined rows, about six seconds faster, with nothing crossing the network.
And the catch, I'm guessing, is that the menu must fit in memory.
That is the one rule. A seven-line menu fits anywhere. A 50 GB customer table does not, and then you are back to the reduce-side join.
Map-side join and the distributed cache
A map-side join combines records from two data sets inside the map phase, without a shuffle. In its most common form, the replicated (or broadcast) join, the smaller data set is copied to every map task, loaded into an in-memory hash table in setup(), and each record of the larger data set is joined by a lookup as it is read; the job can be map-only. The copy is delivered by the distributed cache: files registered with Job.addCacheFile() or the -files, -archives and -libjars generic options are downloaded from HDFS by each NodeManager once per node into a local cache, and made available to every container of the job, optionally under a symlink name given after #. A second form, the composite (sort-merge) map-side join, needs no memory but requires both inputs to be sorted by the join key and partitioned identically into the same number of files.
Give every worker its own copy of the small table, keep it in a dictionary, and add the matching column to each big-table row while reading it. Nothing is sent to a reducer, so it is fast, as long as the small table really is small.
Words you'll meet in this lesson
#name sets the symlink../menu, pointing at the localized file.setNumReduceTasks(0): output written straight from the maps as part-m files.From our lab (Hadoop 3.5.0, 5 lakh orders, menu without “coffee”)
| Map-side join | 3 splits, 3 map tasks, 0 reducers: Map input records 500,000, Map output records 459,636; 18.9 s |
|---|---|
| Reduce-side join | 4 map tasks (3 order splits + menu), 1 reducer: Reduce shuffle bytes 24,448,645; 24.8 s |
| Counters | ORDERS_JOINED = 459,636 and ORDERS_WITHOUT_MENU = 40,364 (all the coffee orders) in both jobs |
| Output | Map-side: part-m-00000/1/2 of 9,020,852 + 8,993,924 + 2,836,286 bytes; reduce-side: one part-r-00000 of 20,851,062 bytes. Same rows once sorted |
| Task log | cache files: [/user/hadoop/data/menu_nocoffee.csv#menu], menu loaded in setup(): 7 items, working dir …/appcache/application_…/container_…_000003/ |
Map-side join at three levels
Beginner A menu card on every counter
- Each counter (map task) gets a printed copy of the menu card (the small table).
- When an order comes in, the counter reads the category off its own card and writes the bill at once. No one walks to the head office.
- This only works because the card is small. If the “card” were a phone book of every student, you couldn't print one for every counter.
Intermediate How the job is built
- Driver:
job.addCacheFile(new URI("/user/hadoop/data/menu.csv#menu"))andjob.setNumReduceTasks(0). - setup(): open
./menu(the symlink), read every line into aHashMap<item, category>. It runs once per map task, not once per record. - map(): look up the item; if found, write the order plus its category; if not, increment a counter. Each line is handled on its own, so the output keeps the input order.
- Output: one
part-m-NNNNNper map task, unsorted. Our three files were 9.0 MB, 9.0 MB and 2.8 MB because the third split was the short tail of the file.
Advanced Where it breaks and what else exists
- Memory: the hash table lives in the map JVM's heap (300 MB in our lab,
-Xmx300m). Java objects take several times the file size, so a 100 MB file can need 300–500 MB of heap. Measure, and raisemapreduce.map.memory.mband-Xmxtogether if needed. - Inner vs outer: unmatched big-side rows can be dropped (inner) or written with an empty value (left outer). Unmatched small-side rows can't be reported, because no single task sees all big-side rows.
- Composite join: if both inputs are already sorted by the key and split into the same number of identically partitioned files (for example, outputs of two jobs with the same partitioner and reducer count),
CompositeInputFormatmerges them in the map with no memory limit. - Today: Hive converts joins with a small table into a map join automatically (
hive.auto.convert.join), and Spark does a broadcast hash join belowspark.sql.autoBroadcastJoinThreshold(10 MB by default). Same idea, same memory rule.
Exam tip: For “Explain map-side join”, give: small table shipped with the distributed cache, loaded into a hash map in setup(), lookup in map(), no reducer and no shuffle; the condition (small side fits in memory); and the alternative for two big tables (reduce-side join, or a composite join when both are sorted and identically partitioned).
Reduce-side vs map-side joins
| Reduce-side join (9.5) | Replicated map-side join | Composite map-side join | |
|---|---|---|---|
| Input condition | None | One side fits in map memory | Both sorted by key, same number of partitions |
| Shuffle | Both sides (24.4 MB in our lab) | None | None |
| Reducers | Needed | Not needed (map-only) | Not needed |
| Lab time | 24.8 s | 18.9 s | (not run) |
| Join types | Inner, left, right, full outer | Inner, outer on the big side | Inner, outer |
| Risk | Hot keys overload one reducer | Out of memory if the small side grows | Preparing sorted inputs costs jobs of its own |
Three ways to put a file in the distributed cache
addCacheFile (driver)
job.addCacheFile(new URI("/data/menu.csv#menu")). The file must already be in HDFS. Our JoinMapSide uses this.
-files (command line)
-files menu.csv,lookup.py: local or HDFS files, uploaded to the staging folder for you. Lesson 9.8 ships menu.csv to Python scripts this way.
-archives
-archives dict.zip#dict: a zip, tar or jar that is unpacked on each node; tasks see a folder.
-libjars
-libjars gson.jar: extra jars added to the task classpath, for libraries your mapper needs.
-files, -archives and -libjars only work if your main() passes the arguments through GenericOptionsParser (or implements Tool and runs via ToolRunner). All our programs do, which is why -D options also worked in Modules 8 and 9.
A replicated join on the lab cluster
Same data as Lesson 9.5: student_orders.csv (5 lakh orders, 3 splits) and a menu with “coffee” removed on purpose. Full scripts are in “Run it on your PC”.
Register the small file and switch off reducers
The client records the HDFS path in the job configuration. Before any map container starts on a node, that node's NodeManager downloads the file once into its local file cache.job.addCacheFile(new URI(rest[1] + "#menu")); // ship the menu, linked as ./menu job.setMapperClass(JoinMapper.class); job.setNumReduceTasks(0); // no reducer, so no shuffle at allLoad it once per task, in setup()
The file is opened by its plain nametry (BufferedReader r = new BufferedReader(new FileReader("menu"))) { String line; while ((line = r.readLine()) != null) { String[] f = line.split(","); category.put(f[0], f[1]); } }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/.menu: YARN placed a symlink with that name in the container's working directory, pointing to the localized copy. No HDFS client code is needed.Join while reading
One hash lookup per order. Orders with no menu row (all coffee orders) are counted and dropped: an inner join. Writing them with an empty category instead would make it a left outer join.String cat = category.get(line.toString().split(",")[3]); if (cat == null) { ctx.getCounter(Join.ORDERS_WITHOUT_MENU).increment(1); return; } out.set(line + "," + cat); ctx.write(out, NullWritable.get());Run it
No “Launched reduce tasks”, no shuffle counters, no Spilled Records: map output goes straight to HDFS. The reduce-side join of the same data needed a reducer and moved 24,448,645 bytes.hadoop jar mr9.jar JoinMapSide /user/hadoop/data/student_orders.csv /user/hadoop/data/menu_nocoffee.csv /out/JoinMapSidenumber 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 took 18943 msLook at the output
One file per map task, rows in the same order as the input (order 1, then order 2). The reduce-side output came out grouped by item instead, because the reducer received it sorted by key.hdfs dfs -ls /out/JoinMapSide hdfs dfs -cat /out/JoinMapSide/part-m-00000 | head -29020852 /out/JoinMapSide/part-m-00000 8993924 /out/JoinMapSide/part-m-00001 2836286 /out/JoinMapSide/part-m-00002 1,S1413,2026-09-14 15:30,dosa,45,meal 2,S0973,2026-09-26 20:11,samosa,18,snackCheck it against the reduce-side join
Same answer, different route. Always verify a rewritten join this way before trusting its speed.diff <(hdfs dfs -cat /out/JoinReduceSide/part-* | sort) <(hdfs dfs -cat /out/JoinMapSide/part-* | sort)identical (459,636 rows each, compared after sorting)
A replicated join that works on test data can fail in production when the “small” table grows: every map task holds its own full copy in heap, so the job dies with OutOfMemoryError or containers are killed for exceeding memory. Check the small side's size in the driver (or in your scheduler) and fall back to a reduce-side join above a limit.
From HDFS to every container, then a lookup per order
Six steps of the JoinMapSide job: register, localize, symlink, load, join, write.
- The job is submitted with the menu registered as a cache file.
- Before tasks start, each worker's NodeManager fetches the menu.
- The task log shows the cache file the task received, with its #menu link name.
- setup() reads ./menu into a HashMap: 7 items.
- Every order is joined during the map; coffee orders are only counted.
- Joined rows land in part-m files, in input order. 18.9 s in total.
- addCacheFile only writes the HDFS path into the job's configuration.
- The NodeManager downloads the file to its local filecache once, for all containers on that node.
- Each container gets a symlink ./menu pointing at the local copy.
- setup() reads the file into a HashMap that lives in the task's heap.
- map() joins each order with one lookup; unknown items only increment a counter.
- Joined rows are written directly to HDFS as part-m files.
Map-side joins at work
1. Enriching transactions with a code table
Billions of UPI or card transactions get the bank name and branch city from a few-MB IFSC or merchant-category table, joined in memory on every mapper.
2. Geo-tagging web logs
A GeoIP range file shipped with -files lets each mapper convert client IPs to city and state while parsing log lines.
3. Hive and Spark do it for you
A Hive query joining a fact table with a small dimension table becomes a map join automatically; Spark SQL broadcasts tables under 10 MB by default. Knowing the idea helps you read their plans and fix out-of-memory errors.
4. Bucketed tables for big-big joins
Two large tables bucketed and sorted on the same key into the same number of files (Hive sort-merge-bucket join) can be joined file by file in the map phase: the composite join idea.
Simplified; organisations differ in tools and policies.
Simulate a broadcast join, split by split
Every “map task” below gets its own copy of the small table, builds a dictionary once (its setup()), and joins its own split. There is no shuffle step at all, and each task's output keeps its input order.
menu_lines = ["chai,drink,15,A", "samosa,snack,18,B", "dosa,meal,45,D", "idli,breakfast,30,C"]
splits = [["1,S1413,2026-09-14 15:30,dosa,45", "2,S0973,2026-09-26 20:11,samosa,18"],
["3,S0007,2026-09-01 13:37,coffee,20", "4,S0002,2026-09-03 08:10,chai,15"],
["5,S0042,2026-09-09 14:05,idli,30"]]
joined = without_menu = 0
for n, split in enumerate(splits):
category = {} # setup(): once per map task
for line in menu_lines:
f = line.split(",")
category[f[0]] = f[1]
out = []
for order in split: # map(): once per record
cat = category.get(order.split(",")[3])
if cat is None:
without_menu += 1
continue
joined += 1
out.append(order + "," + cat)
print(f"part-m-0000{n}: {out}")
print("ORDERS_JOINED =", joined, " ORDERS_WITHOUT_MENU =", without_menu)category = {} # setup(): once per map taskIn Java this is the HashMap filled in setup() from the ./menu symlink: once per task, never per record.cat = category.get(order.split(",")[3])The join itself: one dictionary lookup. No shuffle, no sort, no reducer.without_menu += 1Coffee is missing from this menu, so its order is counted and dropped (an inner join). Our lab counted 40,364 of them.
Run it on your PC: map-side vs reduce-side join
Use the Module 8 lab cluster (lab8) with student_orders.csv and menu.csv from Lesson 9.1 (make_student_orders.py). Copy both Java files of Lessons 9.5 and 9.6 into /tmp/mr9 in the namenode container, compile them into mr9.jar, and run join_lab.sh. It removes “coffee” from the menu first so you can see unmatched orders being counted.
The map-side join
// JoinMapSide.java: map-side join, a map-only job. The small menu is shipped to every map task
// with the distributed cache and loaded into a HashMap in setup(); orders are joined as they are read.
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
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.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.util.GenericOptionsParser;
public class JoinMapSide {
enum Join { ORDERS_JOINED, ORDERS_WITHOUT_MENU }
public static class JoinMapper extends Mapper<LongWritable, Text, Text, NullWritable> {
private final Map<String, String> category = new HashMap<>();
private final Text out = new Text();
@Override
protected void setup(Context ctx) throws IOException {
URI[] files = ctx.getCacheFiles();
System.err.println("cache files: " + java.util.Arrays.toString(files));
// "#menu" in the URI makes YARN create a symlink called "menu" in the task's working directory
try (BufferedReader r = new BufferedReader(new FileReader("menu"))) {
String line;
while ((line = r.readLine()) != null) {
String[] f = line.split(",");
category.put(f[0], f[1]);
}
}
System.err.println("menu loaded in setup(): " + category.size() + " items, working dir = " + new java.io.File(".").getAbsolutePath());
}
@Override
protected void map(LongWritable k, Text line, Context ctx) throws IOException, InterruptedException {
String cat = category.get(line.toString().split(",")[3]);
if (cat == null) { ctx.getCounter(Join.ORDERS_WITHOUT_MENU).increment(1); return; }
ctx.getCounter(Join.ORDERS_JOINED).increment(1);
out.set(line + "," + cat);
ctx.write(out, NullWritable.get());
}
}
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, "map-side join");
job.setJarByClass(JoinMapSide.class);
job.addCacheFile(new URI(rest[1] + "#menu")); // ship the menu to every task, linked as ./menu
job.setMapperClass(JoinMapper.class);
job.setNumReduceTasks(0); // no reducer, so no shuffle at all
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(NullWritable.class);
FileInputFormat.addInputPath(job, new Path(rest[0]));
FileOutputFormat.setOutputPath(job, new Path(rest[2]));
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
// Inside the namenode container, in /tmp/mr9:
// mkdir -p classes && javac -cp $(hadoop classpath) -d classes *.java && jar cf mr9.jar -C classes .
// hadoop jar mr9.jar JoinMapSide /user/hadoop/data/student_orders.csv /user/hadoop/data/menu.csv /out/JoinMapSideReal output of join_lab.sh, which runs both joins. Times vary from run to run; the shuffle bytes and counters do not.
Both joins, compared
# 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.shThe reduce-side half of the same run. MENU_NOT_FIRST=4 shows why the reduce-side reducer must buffer orders: for 4 of 8 items the menu row did not arrive first.
setup() once, map() many times
Watch the dictionary being built once, then used for every order. Coffee is not in the menu.
Worked examples: real problems, solved and traced in memory
Three map-side join decisions: memory sizing, choosing the join, and how much the cache really copies.
Can the student table be broadcast?
Problem statement
The college wants every order joined with its student's department. The student file has 20,000 lines of about 60 bytes. Map tasks have a 300 MB heap. Assuming a hash map entry costs about 5× the raw line, does it fit comfortably (under half the heap)?
20,000 × 60 B · 5× overhead · 300 MB heap
raw 1.14 MB -> about 5.7 MB of heap; fits: True
Approach
- Raw size = lines × bytes per line.
- Heap needed ≈ raw × 5.
- Compare with half the heap.
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.
Which join for which pair of tables?
Problem statement
Pick a join method for each: (a) 2 TB of orders with an 8-line menu; (b) 2 TB of orders with 1.5 TB of payments, both written by jobs with 200 reducers using the same partitioner on order_id, sorted; (c) 2 TB of orders with 1.5 TB of complaints, no special layout.
3 pairs of tables
a -> replicated map-side join (distributed cache) b -> composite map-side join (CompositeInputFormat) c -> reduce-side join
Approach
- Small side fits in memory → replicated map-side join.
- Both sorted and identically partitioned → composite map-side join.
- Otherwise → reduce-side join.
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.
How many copies does the distributed cache make?
Problem statement
A job with 400 map tasks runs on 50 workers (8 tasks per worker) and ships a 30 MB lookup file with -files. How much is downloaded from HDFS if the file is localized per node, compared with a naive design that reads it from HDFS in every task?
400 tasks · 50 nodes · 30 MB
distributed cache: 1,500 MB naive per task: 12,000 MB
Approach
- Localization downloads once per node per job.
- Naive: once per task.
- Compare.
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.
Check yourself
Quick revision
Lesson 9.6 in one screen
- Replicated (broadcast) join: ship the small table to every map task, load into a HashMap in
setup(), look up inmap(); map-only, no shuffle. - Ship files with
job.addCacheFile(uri#name), or-files,-archives(unpacked),-libjars(classpath); needs GenericOptionsParser/ToolRunner. - NodeManagers localize cache files once per node; each container gets a symlink (
./menu). - Lab: 459,636 joined rows, 0 bytes shuffled, 18.9 s vs reduce-side 24.8 s and 24,448,645 bytes shuffled; same rows.
- Output: one
part-mper map task, in input order (no sort). - Rule: small side must fit in the map heap (Java objects take several times the file size).
- Two big tables: reduce-side join, or composite join if both are sorted and identically partitioned.
- Modern equivalents: Hive map join, Spark broadcast hash join (10 MB default threshold).
Interview and viva questions
What is a map-side join in Hadoop?
A join performed in the map phase without a shuffle. In the replicated form, the smaller data set is distributed to every map task (usually with the distributed cache), loaded into memory in setup(), and each record of the larger data set is joined by a lookup. The job can be map-only, so it avoids sorting and transferring data to reducers.
What is the distributed cache in Hadoop?
A mechanism to make read-only files, archives and jars available to all tasks of a job. Files are registered with Job.addCacheFile() or the -files, -archives and -libjars options; each NodeManager downloads them from HDFS once per node (localization) and links them into each container's working directory, optionally under a name given after # in the URI.
When should I use a map-side join instead of a reduce-side join?
When one input is small enough to fit in each map task's memory (with room to spare), or when both inputs are already sorted by the join key and partitioned identically. Otherwise use a reduce-side join, which works for any sizes but shuffles both inputs.
Why is a map-side join faster?
It avoids the shuffle: no map output sorting and spilling, no network transfer to reducers, no merge and no reduce tasks. In our lab the reduce-side join moved 24.4 MB across the network and took 24.8 s; the map-side join moved nothing and took 18.9 s for the same 459,636 rows.
What happens if the small table is too big for a map-side join?
Each map task holds its own full copy in heap, so the tasks fail with OutOfMemoryError or are killed by YARN for exceeding container memory, and retries fail the same way. Increase map memory only if the table is moderately sized; otherwise switch to a reduce-side join or pre-partition both inputs for a composite join.
Practice problems
Map-side join exercises using the real numbers from the lab.
Check the counters
Show that ORDERS_JOINED + ORDERS_WITHOUT_MENU equals Map input records.
459,636 + 40,364
True
Show solution and explanation
print(459636 + 40364 == 500000)How it works: True: every order was either joined or counted as having no menu row.
Output sizes
Add the three part-m file sizes and compare with Bytes Written.
9,020,852 + 8,993,924 + 2,836,286
20851062 vs 20851062
Show solution and explanation
print(9020852 + 8993924 + 2836286, "vs", 20851062)How it works: 20,851,062 both: the same bytes as the reduce-side join's single part-r file.
Time saved
How much faster was the map-side join, in seconds and percent?
24,828 ms vs 18,943 ms
5.9 s faster, 24% less time
Show solution and explanation
print(f"{(24828 - 18943) / 1000:.1f} s faster, {1 - 18943 / 24828:.0%} less time")How it works: About 5.9 s (24%) on a tiny lab; on terabytes the saved shuffle matters far more.
Build the lookup
Read menu lines into a dictionary item → (category, price) and join two orders, printing the order with category and menu price.
menu + 2 orders
7,S0010,2026-09-02 13:05,thali,80 -> meal 80 8,S0011,2026-09-02 13:06,chai,15 -> drink 15
Show solution and explanation
menu = ["chai,drink,15,A", "thali,meal,80,D"]
lookup = {}
for line in menu:
item, cat, price, counter = line.split(",")
lookup[item] = (cat, int(price))
for o in ["7,S0010,2026-09-02 13:05,thali,80", "8,S0011,2026-09-02 13:06,chai,15"]:
cat, price = lookup[o.split(",")[3]]
print(o, "->", cat, price)How it works: The same setup()/map() pattern, carrying two columns from the small table.
Check the bill
Using the joined lookup, count orders whose amount differs from the menu price.
3 orders
1 mismatched: ['2,S2,t,dosa,40']
Show solution and explanation
price = {"chai": 15, "dosa": 45}
orders = ["1,S1,t,chai,15", "2,S2,t,dosa,40", "3,S3,t,dosa,45"]
bad = [o for o in orders if int(o.split(",")[4]) != price[o.split(",")[3]]]
print(len(bad), "mismatched:", bad)How it works: 1 mismatch. A map-side join plus a counter is a cheap data-quality check over billions of rows.
Cache downloads
3 workers ran our 3 map tasks. How many times was the menu downloaded from HDFS if each task landed on a different node, and if all 3 landed on one node?
3 tasks
tasks on 3 node(s): 3 download(s) tasks on 1 node(s): 1 download(s)
Show solution and explanation
for nodes_used in (3, 1):
print(f"tasks on {nodes_used} node(s): {nodes_used} download(s)")How it works: Once per node per job, however many tasks run there.
Broadcast or shuffle?
A daily job joins 3 TB of transactions with a 5 MB merchant table. A reduce-side join shuffles the 3 TB; at 1 GB/s of total network bandwidth, how long does the shuffle alone take, and what does broadcasting cost instead to 400 nodes?
3 TB · 1 GB/s · 5 MB × 400 nodes
shuffle ~51 min of network time; broadcast 2.0 GB in total
Show solution and explanation
shuffle_s = 3 * 1024 / 1
broadcast_mb = 5 * 400
print(f"shuffle ~{shuffle_s / 60:.0f} min of network time; broadcast {broadcast_mb / 1024:.1f} GB in total")How it works: About 51 minutes of network time versus copying 2 GB once. That is why every engine broadcasts small tables.
Composite join by hand
Two inputs are sorted by order_id and split into the same 2 partitions. Merge-join partition by partition without loading either fully into memory.
2 × 2 sorted partitions
1 dosa UPI 3 chai UPI 4 idli card 6 thali UPI
Show solution and explanation
A = [[(1, "dosa"), (3, "chai")], [(4, "idli"), (6, "thali")]]
B = [[(1, "UPI"), (2, "cash"), (3, "UPI")], [(4, "card"), (6, "UPI")]]
for pa, pb in zip(A, B):
i = j = 0
while i < len(pa) and j < len(pb):
if pa[i][0] == pb[j][0]:
print(pa[i][0], pa[i][1], pb[j][1]); i += 1; j += 1
elif pa[i][0] < pb[j][0]:
i += 1
else:
j += 1How it works: Orders 1, 3, 4 and 6 are joined, walking both sorted lists once. This only works because both sides use the same partitioning and sort order.
Real-world scenario questions (asked in company interviews)
Join strategies are a favourite interview topic for data engineers, from MapReduce to Spark.
“What is the difference between a map-side and a reduce-side join?”
Asked in almost every Hadoop interview.
The interviewer is checking: where the join happens and the condition.
Show a strong answer
- Reduce-side: both inputs are mapped to (join key, tagged record) and shuffled; the reducer combines them. Works for any sizes but moves all data.
- Map-side: the small table is broadcast with the distributed cache and looked up in each mapper; no shuffle, often map-only.
- Condition: the small side fits in mapper memory (or both inputs are sorted and identically partitioned for a composite join).
A map-side join job that ran for a year now fails with OutOfMemoryError
Every map attempt fails four times; nothing in the code changed.
The interviewer is checking: that you suspect the growing small side.
Show a strong answer
- Check the size of the cached file: the “small” table has probably grown. Each task holds a full copy in heap.
- Short term: raise mapreduce.map.memory.mb and -Xmx together, or load only the needed columns and rows.
- Long term: add a size check in the driver and fall back to a reduce-side join above a threshold, as Hive and Spark do automatically.
“How does a file added with -files reach the mapper?”
A follow-up after you mention the distributed cache.
The interviewer is checking: the localization path.
Show a strong answer
- The client uploads local files to the job's staging directory in HDFS and records them in job.xml.
- Before launching containers, each NodeManager downloads (localizes) the files once into its local cache.
- Each container gets a symlink in its working directory, so the task opens the file by its plain name.
“Spark is doing a sort-merge join with a 3 MB table. Why, and how do you fix it?”
A slow Spark job joins a fact table with a small lookup table.
The interviewer is checking: broadcast joins beyond MapReduce.
Show a strong answer
- Spark broadcasts only when it knows the table is below spark.sql.autoBroadcastJoinThreshold (10 MB default); missing statistics or a filtered subquery can hide the size.
- Use a broadcast hint (broadcast(df) or /*+ BROADCAST(t) */) or compute statistics.
- It is the same idea as the MapReduce replicated join: ship the small side, avoid shuffling the big side.