Secondary sort

Inside a reduce group, values arrive in no particular order. Secondary sort makes the shuffle sort them for you: put the sort field into the key, then partition and group by the natural key only. This lesson builds it for 2,000 students' orders on a real cluster and shows what goes wrong when one piece is missing.

Module 9 · MapReduce in depth⏱ 28 minAdvanced2,000 groups vs 490,794, measuredComposite key + grouping comparatorRuns in your browser (Python)8 practice problems
1 The story

The first and the last order

The canteen manager wants three facts for every one of 2,000 students: the first order of September, the last one, and the longest break between two orders. Riya writes a reducer that loops over a student's orders. The answers are nonsense: the “first” order it prints is from 14 September and the “last” from the 7th. In Lesson 8.5 she had already seen why: inside one key, values arrive in no particular order (chai [104221, 150350, 113456]).

reduce(S1413, values) — plain 09-14 15:30 dosa 09-20 14:44 coffee 09-25 15:14 chai 09-07 09:56 samosa … 184 orders order of arrival: not by time (here: file order) sort by (student, time) reduce(S1413, values) — secondary sort 09-01 14:18 vada pav ← first 09-01 14:36 chai … (in time order) 09-30 16:30 samosa ← last one pass, nothing kept in memory The shuffle already sorts keys. Put the time inside the key and it sorts the values too. myphatshala.in
Riya

Easy fix, Sir: I'll put the 184 orders in an ArrayList and sort them in the reducer.

Arjun Sir

For S1413, fine. Our heaviest student has 3,899 orders; a web user can have millions of clicks. Sorting in the reducer means holding all of them in a 300 MB heap. Hadoop is already sorting terabytes for you in the shuffle, on disk if needed. Let it sort by time as well.

Kabir

But the shuffle sorts by key, and the key is the student.

Arjun Sir

So we make the time part of the key: (student, time). Then we tell Hadoop two things: send all keys of one student to the same reducer, and treat them as one group. That's secondary sort. Our job gave 2,000 correct lines; without the grouping part it gave 490,794 useless ones.

2 Definition

Secondary sort

Formal definition (write this in exams)

Secondary sort is a MapReduce design pattern that delivers the values of each key to the reducer in a defined order, using the framework's shuffle sort instead of sorting in memory. The mapper emits a composite key made of the natural key (the grouping field, e.g. student) and the secondary field (e.g. time) — the value-to-key conversion. Three settings complete it: the sort comparator (the composite key's compareTo, or setSortComparatorClass) orders keys by natural key and then secondary field; a custom partitioner that uses only the natural key, so all records of one natural key reach the same reducer; and a grouping comparator (setGroupingComparatorClass) that compares only the natural key, so consecutive keys with the same natural key form one reduce() call whose values are already in secondary order.

In simple words

Put the field you want sorted (time) into the key next to the field you want grouped (student). Hadoop sorts by both, but you tell it to send and group by the student only. Each reduce() call then gets one student's orders, oldest first.

Words you'll meet in this lesson

Natural keyThe field you group by: the student.
Secondary fieldThe field the values should be ordered by: the order time.
Composite keyA key holding both: StudentTime(student, time).
Sort comparatorDecides the order of keys in the shuffle (default: the key's compareTo).
Grouping comparatorDecides which consecutive keys go into the same reduce() call.
Natural-key partitionerSends every key of one student to the same reducer.
Value-to-key conversionMoving a field from the value into the key so the framework sorts it.
SessionizationSplitting a user's time-ordered events into sessions by gaps.

From our lab (Hadoop 3.5.0, 5 lakh orders, 2,000 students)

With grouping comparatorReduce input groups 2,000 · Reduce output records 2,000 · 26.2 s
A heavy studentS0001: 3,899 orders, first 2026-09-01 08:09 poha, last 2026-09-30 20:47 samosa, longest gap 12 h (19:55 → 08:01 next day)
A light studentS1413: 184 orders, first 2026-09-01 14:18 vada pav, last 2026-09-30 16:30 samosa, longest gap 22 h; plain Linux sort agrees
Order check in the reducerTask log: “values were in time order in every group: true”
Without grouping comparatorReduce input groups 490,794 = output lines: one reduce() call per (student, minute), e.g. “S0001 1 orders … longest gap 0 h (null -> null)”

Secondary sort at three levels

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

Beginner The attendance register

  • The office sorts all canteen slips by roll number and then by time, in one big pile. That's the sort comparator.
  • All slips of roll number S1413 go to the same clerk, whatever the time on them. That's the partitioner.
  • The clerk treats all consecutive S1413 slips as one bundle, not one bundle per time. That's the grouping comparator.
  • The clerk only has to read the top slip (first order) and keep reading to the last one; nothing to sort by hand.

Intermediate The three pieces in code

  • Key: StudentTime implements WritableComparable; compareTo compares student, then time.
  • Partitioner: (k.student.hashCode() & Integer.MAX_VALUE) % n. If it used the whole key, S1413's orders would be spread over every reducer.
  • Grouping comparator: a WritableComparator subclass comparing student only, set with job.setGroupingComparatorClass(StudentGrouping.class).
  • Leave out the partitioner and you get wrong answers once there are 2+ reducers. Leave out the grouping comparator and every distinct (student, minute) becomes its own call: 490,794 in our lab.

Advanced What really happens while you iterate

  • The framework reuses one key object. As you step through the values, it deserializes the next record's key into the same object: key.time changes on every step, while key.student stays the same within the group.
  • That is why our mapper also puts the time in the value: reading the value is the clear, safe way. You can read key.time inside the loop, but never keep a reference to the key and expect it to stay put.
  • For descending order (latest first) add a sort comparator with setSortComparatorClass that reverses the time part, keeping the grouping on student.
  • For speed, production code often uses a raw comparator that compares serialized bytes without creating objects. Correctness first, then that optimisation.

Exam tip: For “What is secondary sort in MapReduce?”, list the four parts: composite key (natural key + secondary field), sort comparator, partitioner on the natural key, grouping comparator on the natural key. Then say why: values are otherwise unordered and sorting them in reducer memory does not scale.

3 Types and ways to use it

Three ways to get ordered values

ApproachHowMemory in the reducerWhen it fits
Sort in the reducerCopy values to a list, Collections.sortAll values of the biggest keySmall, bounded groups only
Secondary sortComposite key + partitioner + grouping comparatorConstant (one value at a time)Any size; the standard answer
Total order (Lesson 9.2)TotalOrderPartitioner over sorted rangesConstantThe whole output globally sorted, not per group

Where secondary sort is used

First and last event

First and last purchase per customer, as in our lab: read the first value and keep the last one.

this lesson

Sessionization

Clicks of a user in time order; a gap above 30 minutes starts a new session.

web analytics

Top-N per group

Sort by (store, revenue descending) and take the first N values of each group.

ranking

Joins with the small side first

Tag menu records 0 and orders 1 in the key, so the menu row arrives first and orders need no buffering (Lesson 9.5 saw MENU_NOT_FIRST=4).

joins

Spark (repartitionAndSortWithinPartitions), Hive (DISTRIBUTE BY student SORT BY time) and SQL window functions (OVER (PARTITION BY student ORDER BY time)) all do this same partition-then-sort under the hood.

4 Step by step

Building FirstLastOrder

Run on the Module 8 lab cluster with secondary_sort_lab.sh (in “Run it on your PC”). Input: student_orders.csv, 5 lakh orders order_id,student_id,time,item,amount.

  1. Move the time into the key

    public static class StudentTime implements WritableComparable<StudentTime> {
      String student = "", time = "";
      public int compareTo(StudentTime o) {            // sort: student, then time
        int c = student.compareTo(o.student);
        return c != 0 ? c : time.compareTo(o.time);
      }
    }
    // in map():
    key.student = f[1]; key.time = f[2];
    val.set(f[2] + " " + f[3]);                        // "2026-09-01 14:18 vada pav"
    ctx.write(key, val);
    The time “2026-09-01 14:18” sorts correctly as text because it is written year-month-day hour:minute. A format like 1/9/2026 would sort wrongly.
  2. Partition by student only

    public int getPartition(StudentTime k, Text v, int n) {
      return (k.student.hashCode() & Integer.MAX_VALUE) % n;
    }
    The time must not influence the partition, or one student's orders would land on several reducers. The key's hashCode() also uses only the student, as a second guard.
  3. Group by student only

    public static class StudentGrouping extends WritableComparator {
      public StudentGrouping() { super(StudentTime.class, true); }
      public int compare(WritableComparable a, WritableComparable b) {
        return ((StudentTime) a).student.compareTo(((StudentTime) b).student);
      }
    }
    job.setPartitionerClass(StudentPartitioner.class);
    job.setGroupingComparatorClass(StudentGrouping.class);
    The true in super(…, true) tells the comparator to create key instances so it can deserialize and compare them.
  4. Read the values in order

    for (Text v : vals) {                  // arrives in time order thanks to the sort
      if (first == null) first = s;
      // gap = minutes between prev and s; remember the largest
      prev = s; last = s; n++;
    }
    One pass, three variables. Nothing is stored, whether the student has 184 orders or 18 lakh.
  5. Run it

    hadoop jar mr9.jar FirstLastOrder /user/hadoop/data/student_orders.csv /out/ss_true
    hdfs dfs -cat /out/ss_true/part-r-00000 | grep -E "^S1413|^S0001"
    Reduce input groups=2000 Reduce input records=500000 Reduce output records=2000 S0001 3899 orders | first 2026-09-01 08:09 poha | last 2026-09-30 20:47 samosa | longest gap 12 h (2026-09-12 19:55 -> 2026-09-13 08:01) S1413 184 orders | first 2026-09-01 14:18 vada pav | last 2026-09-30 16:30 samosa | longest gap 22 h (2026-09-16 11:50 -> 2026-09-17 09:52) values were in time order in every group: true
    2,000 groups for 2,000 students. The last line comes from the reducer's own check in its task log. A plain Linux sort on S1413's lines gives the same first (09-01 14:18 vada pav) and last (09-30 16:30 samosa) order.
  6. Remove the grouping comparator

    hadoop jar mr9.jar FirstLastOrder -D ss.grouping=false /user/hadoop/data/student_orders.csv /out/ss_false
    Reduce input groups=490794 Reduce output records=490794 S0001 1 orders | first 2026-09-01 08:09 poha | last 2026-09-01 08:09 poha | longest gap 0 h (null -> null) S0001 2 orders | first 2026-09-01 08:15 poha | last 2026-09-01 08:15 thali | longest gap 0 h (2026-09-01 08:15 -> 2026-09-01 08:15) S0001 1 orders | first 2026-09-01 08:18 vada pav | last 2026-09-01 08:18 vada pav | longest gap 0 h (null -> null)
    Now the full key decides the groups: every distinct (student, minute) is its own reduce() call. Only orders in the very same minute (S0001's poha and thali at 08:15) share a call. The job still “succeeds”, with 490,794 wrong lines.

Secondary sort does not fail loudly when a piece is missing. Without the partitioner, results are wrong only when there are 2+ reducers; without the grouping comparator the output just gets bigger. Always check Reduce input groups: it must equal the number of natural keys (2,000 here).

5 See it happen

From mixed-up orders to one sorted group

Six steps for two students: map emits composite keys, the partitioner routes by student, the shuffle sorts by (student, time), grouping makes one call per student.

Composite key, partition, sort, group 6 steps
What you see hadoop@namenode : /tmp/mr9 $ hadoop jar mr9.jar FirstLastOrder …number of splits:3map 0% reduce 0% map 100% reduce 0%Map output records=500000 map 100% reduce 67%(merging sorted map outputs) Reduce input records=500000Reduce input groups=2000 S1413 184 orders first 2026-09-01 14:18 vada pav last 2026-09-30 16:30 samosa longest gap 22 h $ … -D ss.grouping=falseReduce input groups=490794S0001 1 orders | … (null -> null) myphatshala.in
Behind the screen map output (unsorted) (S1413, 09-30 16:30) samosa(S0001, 09-01 08:15) poha(S1413, 09-01 14:18) vada pav(S0001, 09-01 08:09) poha(S1413, 09-01 14:36) chai value-to-key: the time is now part of the key partition by student only reducer 0(S0001, 08:15)(S0001, 08:09) reducer 1(S1413, 09-30 16:30)(S1413, 09-01 14:18)(S1413, 09-01 14:36) all of a student's keys reach the same reducer reducer 1: sort by (student, time) (S1413, 09-01 14:18) vada pav(S1413, 09-01 14:36) chai …(S1413, 09-30 16:30) samosa compareTo: student first, then time group by student only one reduce()call: S1413 (S1413, 09-01 14:18) vada pav(S1413, 09-01 14:36) chai …(S1413, 09-30 16:30) samosa grouping comparator ignores the time iterate once first = 09-01 14:18 vada pavgap = max(minutes between neighbours)last = 09-30 16:30 samosa key.time changes on every step:14:18 → 14:36 → 16:04 → … the key object is reused constant memory, any group size no grouping comparator reduce(S0001, 08:09): 1 value reduce(S0001, 08:15): 2 values reduce(S0001, 08:18): 1 value … 490,794 calls the full key decides the groups again myphatshala.in
6 Real-world use

Secondary sort at work

3 sessions

1. Web and app sessionization

Clickstream jobs sort each user's events by timestamp and cut a new session whenever the gap exceeds 30 minutes, to count sessions, session length and bounce rate.

Click logs→key (user, time)→Sessions per user
balance aftereach txn

2. Running balances in banking

Transactions of each account, sorted by posting time, give the running balance and the first overdraft moment without loading an account's full history into memory.

Transactions→key (account, time)→Statements
1. thali2. dosa3. idli

3. Top-N per group

Best-selling products per store or top scorers per exam centre: sort by (group, value descending) and keep the first N values of each group.

Sales→key (store, −revenue)→Top 10 per store
menu row (tag 0)orders (tag 1)

4. Memory-safe reduce-side joins

Adding a tag to the key so the small table's row sorts first lets the reducer stream the big side without buffering it, the fix for the buffering seen in Lesson 9.5.

Menu + orders→key (item, tag)→Streamed join

Simplified; organisations differ in tools and policies.

7 Run the code

Simulate composite key, partition and grouping

This model does what the framework does: sort map output by the full key (student, time), send each key to a reducer by the student only, then cut groups where the student changes. Switch GROUP_BY_STUDENT to False to see a job without a grouping comparator.

orders = [("S1413", "09-14 15:30", "dosa"), ("S0001", "09-01 08:15", "poha"),
          ("S1413", "09-01 14:18", "vada pav"), ("S0001", "09-01 08:09", "poha"),
          ("S1413", "09-30 16:30", "samosa"), ("S0001", "09-01 08:15", "thali")]
REDUCERS = 3
GROUP_BY_STUDENT = True

def partition(student):                       # natural key only
    return sum(map(ord, student)) % REDUCERS

for r in range(REDUCERS):
    mine = [o for o in orders if partition(o[0]) == r]
    mine.sort(key=lambda o: (o[0], o[1]))     # sort comparator: student, then time
    groups = []
    for student, time, item in mine:
        same = groups and (groups[-1][0] == student if GROUP_BY_STUDENT
                           else groups[-1][0] == (student, time))
        if not same:
            groups.append([student if GROUP_BY_STUDENT else (student, time), []])
        groups[-1][1].append(f"{time} {item}")
    for key, vals in groups:
        print(f"reducer {r}: reduce({key}) -> first {vals[0]!r}, last {vals[-1]!r}, {len(vals)} values")
reducer 0: reduce(S0001) -> first '09-01 08:09 poha', last '09-01 08:15 thali', 3 values reducer 2: reduce(S1413) -> first '09-01 14:18 vada pav', last '09-30 16:30 samosa', 3 values
  • return sum(map(ord, student)) % REDUCERSOnly the student decides the reducer, never the time. (Hadoop uses the hash code; any function of the student works.)
  • mine.sort(key=lambda o: (o[0], o[1])) # sort comparator: student, then timeThe shuffle sorts by the full composite key: this is the key's compareTo.
  • same = groups and (groups[-1][0] == student if GROUP_BY_STUDENTThe grouping comparator: a new reduce() call starts only when the student changes.

Run it on your PC: first and last order per student

Use the Module 8 lab cluster and student_orders.csv from make_student_orders.py (Lesson 9.1). Copy FirstLastOrder.java and the script into the namenode container under /tmp/mr9, compile all Module 9 classes into mr9.jar, then run the script. It runs the job twice (with and without the grouping comparator) and checks one student with plain Linux tools.

Secondary sort job

// FirstLastOrder.java: secondary sort. For every student: first order, last order and the
// longest gap between two orders, reading the orders in time order WITHOUT sorting in the reducer.
// Key = (student, time): sorted by both, partitioned and grouped by student only.
import java.io.DataInput;
import java.io.DataOutput;
import java.io.IOException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.io.WritableComparable;
import org.apache.hadoop.io.WritableComparator;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Partitioner;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import org.apache.hadoop.util.GenericOptionsParser;

public class FirstLastOrder {

  public static class StudentTime implements WritableComparable<StudentTime> {
    String student = "", time = "";
    public StudentTime() { }
    @Override public void write(DataOutput out) throws IOException { out.writeUTF(student); out.writeUTF(time); }
    @Override public void readFields(DataInput in) throws IOException { student = in.readUTF(); time = in.readUTF(); }
    @Override public int compareTo(StudentTime o) {               // the SORT order: student, then time
      int c = student.compareTo(o.student);
      return c != 0 ? c : time.compareTo(o.time);
    }
    @Override public int hashCode() { return student.hashCode(); }
    @Override public boolean equals(Object o) { return o instanceof StudentTime && compareTo((StudentTime) o) == 0; }
  }

  // 1. Partition by student only, so all of a student's orders reach the same reducer
  public static class StudentPartitioner extends Partitioner<StudentTime, Text> {
    @Override public int getPartition(StudentTime k, Text v, int n) { return (k.student.hashCode() & Integer.MAX_VALUE) % n; }
  }

  // 2. Group by student only, so one reduce() call gets ALL of a student's orders (already time-sorted)
  public static class StudentGrouping extends WritableComparator {
    public StudentGrouping() { super(StudentTime.class, true); }
    @Override public int compare(WritableComparable a, WritableComparable b) {
      return ((StudentTime) a).student.compareTo(((StudentTime) b).student);
    }
  }

  public static class M extends Mapper<LongWritable, Text, StudentTime, Text> {
    private final StudentTime key = new StudentTime();
    private final Text val = new Text();
    @Override
    protected void map(LongWritable k, Text line, Context ctx) throws IOException, InterruptedException {
      String[] f = line.toString().split(",");          // order_id,student_id,time,item,amount
      key.student = f[1]; key.time = f[2];
      val.set(f[2] + " " + f[3]);
      ctx.write(key, val);
    }
  }

  public static class R extends Reducer<StudentTime, Text, Text, Text> {
    private static final DateTimeFormatter F = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
    private boolean sorted = true;
    @Override
    protected void reduce(StudentTime k, Iterable<Text> vals, Context ctx) throws IOException, InterruptedException {
      String first = null, last = null, prev = null, gapFrom = null, gapTo = null;
      long n = 0, maxGap = -1;
      for (Text v : vals) {                              // arrives in time order thanks to the sort
        String s = v.toString();
        if (first == null) first = s;
        if (prev != null) {
          if (s.substring(0, 16).compareTo(prev.substring(0, 16)) < 0) sorted = false;   // compare the time part only
          long gap = Duration.between(LocalDateTime.parse(prev.substring(0, 16), F), LocalDateTime.parse(s.substring(0, 16), F)).toMinutes();
          if (gap > maxGap) { maxGap = gap; gapFrom = prev.substring(0, 16); gapTo = s.substring(0, 16); }
        }
        prev = s; last = s; n++;
      }
      ctx.write(new Text(k.student), new Text(n + " orders | first " + first + " | last " + last
          + " | longest gap " + (maxGap / 60) + " h (" + gapFrom + " -> " + gapTo + ")"));
    }
    @Override
    protected void cleanup(Context ctx) { System.err.println("values were in time order in every group: " + sorted); }
  }

  public static void main(String[] args) throws Exception {
    Configuration conf = new Configuration();
    String[] rest = new GenericOptionsParser(conf, args).getRemainingArgs();
    Job job = Job.getInstance(conf, "first last order (secondary sort)");
    job.setJarByClass(FirstLastOrder.class);
    job.setMapperClass(M.class);
    job.setReducerClass(R.class);
    job.setPartitionerClass(StudentPartitioner.class);
    if (conf.getBoolean("ss.grouping", true)) job.setGroupingComparatorClass(StudentGrouping.class);
    job.setMapOutputKeyClass(StudentTime.class);
    job.setMapOutputValueClass(Text.class);
    job.setOutputKeyClass(Text.class);
    job.setOutputValueClass(Text.class);
    FileInputFormat.addInputPath(job, new Path(rest[0]));
    FileOutputFormat.setOutputPath(job, new Path(rest[1]));
    System.exit(job.waitForCompletion(true) ? 0 : 1);
  }
}

// In the mr-lab folder:
//   docker exec lab8-namenode-1 mkdir -p /tmp/mr9
//   docker cp FirstLastOrder.java lab8-namenode-1:/tmp/mr9/
//   docker exec -it lab8-namenode-1 bash
// Inside the container:
//   cd /tmp/mr9
//   mkdir -p classes && javac -cp $(hadoop classpath) -d classes *.java && jar cf mr9.jar -C classes .
//   hadoop jar mr9.jar FirstLastOrder /user/hadoop/data/student_orders.csv /out/ss_true
== grouping comparator: true 26217 ms Reduce input groups=2000 Reduce input records=500000 Reduce output records=2000 S0001 3899 orders | first 2026-09-01 08:09 poha | last 2026-09-30 20:47 samosa | longest gap 12 h (2026-09-12 19:55 -> 2026-09-13 08:01) S0002 4015 orders | first 2026-09-01 08:02 vada pav | last 2026-09-30 20:56 vada pav | longest gap 11 h (2026-09-24 20:08 -> 2026-09-25 08:03) S0003 4031 orders | first 2026-09-01 08:04 idli | last 2026-09-30 20:56 thali | longest gap 11 h (2026-09-17 20:15 -> 2026-09-18 08:02) == grouping comparator: false 25880 ms Reduce input groups=490794 Reduce input records=500000 Reduce output records=490794 S0001 1 orders | first 2026-09-01 08:09 poha | last 2026-09-01 08:09 poha | longest gap 0 h (null -> null) S0001 2 orders | first 2026-09-01 08:15 poha | last 2026-09-01 08:15 thali | longest gap 0 h (2026-09-01 08:15 -> 2026-09-01 08:15) S0001 1 orders | first 2026-09-01 08:18 vada pav | last 2026-09-01 08:18 vada pav | longest gap 0 h (null -> null) == a light user and the heaviest user S0001 3899 orders | first 2026-09-01 08:09 poha | last 2026-09-30 20:47 samosa | longest gap 12 h (2026-09-12 19:55 -> 2026-09-13 08:01) S1413 184 orders | first 2026-09-01 14:18 vada pav | last 2026-09-30 16:30 samosa | longest gap 22 h (2026-09-16 11:50 -> 2026-09-17 09:52) == the reducer's own check (task log) values were in time order in every group: true == check in plain Linux: S1413's first and last order 2026-09-01 14:18,vada pav 2026-09-30 16:30,samosa

Real output of secondary_sort_lab.sh (below). Timings vary. With grouping: 2,000 groups for 2,000 students. Without: 490,794 groups, one per distinct (student, minute); “longest gap 0 h” there is -1 minutes divided by 60 in Java integer arithmetic, because a one-order group has no gap at all.

The experiment script

# secondary_sort_lab.sh: values in time order inside each student's group, with and without a grouping comparator
cd /tmp/mr9
D=/user/hadoop/data/student_orders.csv
for g in true false; do
  hdfs dfs -rm -r -f -skipTrash /out/ss_$g > /dev/null 2>&1
  start=$(date +%s%N)
  hadoop jar mr9.jar FirstLastOrder -D ss.grouping=$g $D /out/ss_$g > /tmp/ss_$g.log 2>&1
  echo "== grouping comparator: $g   $(( ($(date +%s%N) - start) / 1000000 )) ms"
  grep -E "Reduce input records|Reduce input groups|Reduce output records" /tmp/ss_$g.log | sed 's/^\s*/  /'
  hdfs dfs -cat /out/ss_$g/part-r-00000 2>/dev/null | head -3 | cut -c1-200
done
echo "== a light user and the heaviest user"
hdfs dfs -cat /out/ss_true/part-r-00000 | grep -E "^S1413|^S0001" | cut -c1-200
echo "== the reducer's own check (task log)"
app=$(grep -o "application_[0-9_]*" /tmp/ss_true.log | head -1); sleep 5
yarn logs -applicationId $app -log_files stderr 2>/dev/null | grep "time order"
echo "== check in plain Linux: S1413's first and last order"
grep ",S1413," student_orders.csv | cut -d, -f3,4 | sort | sed -n '1p;$p'

# Inside the namenode container, in /tmp/mr9 (student_orders.csv copied there too):
#   bash secondary_sort_lab.sh
== grouping comparator: true 26217 ms Reduce input groups=2000 Reduce input records=500000 Reduce output records=2000 S0001 3899 orders | first 2026-09-01 08:09 poha | last 2026-09-30 20:47 samosa | longest gap 12 h (2026-09-12 19:55 -> 2026-09-13 08:01) S0002 4015 orders | first 2026-09-01 08:02 vada pav | last 2026-09-30 20:56 vada pav | longest gap 11 h (2026-09-24 20:08 -> 2026-09-25 08:03) S0003 4031 orders | first 2026-09-01 08:04 idli | last 2026-09-30 20:56 thali | longest gap 11 h (2026-09-17 20:15 -> 2026-09-18 08:02) == grouping comparator: false 25880 ms Reduce input groups=490794 Reduce input records=500000 Reduce output records=490794 S0001 1 orders | first 2026-09-01 08:09 poha | last 2026-09-01 08:09 poha | longest gap 0 h (null -> null) S0001 2 orders | first 2026-09-01 08:15 poha | last 2026-09-01 08:15 thali | longest gap 0 h (2026-09-01 08:15 -> 2026-09-01 08:15) S0001 1 orders | first 2026-09-01 08:18 vada pav | last 2026-09-01 08:18 vada pav | longest gap 0 h (null -> null)

The first half of the same run. Compare Reduce input groups: it must equal the number of natural keys.

8 Line by line

Grouping sorted composite keys

The keys below are already sorted by (student, time). Watch how the grouping rule decides where one reduce() call ends.

Worked examples: real problems, solved and traced in memory

Three secondary-sort design problems: building the key, sizing the memory you save, and a session count.

Example 1BeginnerDesign · key

Design the key for “latest score per student”

Problem statement

Exam attempts look like student,subject,attempt_time,score. For each (student, subject) you need the score of the latest attempt. What is the natural key, the secondary field, and which value do you read in the reducer?

Input
4 attempts
Expected output
S1 java latest 09-05 score 55
S1 maths latest 09-20 score 78
S2 maths latest 09-11 score 90

Approach

  1. Natural key = (student, subject): the group.
  2. Secondary field = attempt_time, sorted descending.
  3. The reducer reads only the first value of each group.

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 · memory

How much memory does sorting in the reducer need?

Problem statement

A clickstream job groups by user. The heaviest user has 3 crore events in a month, each about 120 bytes as a Java object. The reducer heap is 3 GB. Can it sort that user's events in memory? How much does secondary sort need?

Input
3 crore events × 120 B · 3 GB heap
Expected output
in-memory sort needs 3.4 GB of 3 GB heap -> OutOfMemoryError
secondary sort needs about 240 bytes per group at any moment

Approach

  1. Memory = events × bytes per event.
  2. Compare with the heap.
  3. Secondary sort keeps only the current and previous value.

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 3AdvancedPattern · sessions

Count sessions with a 30-minute gap

Problem statement

One user's click times arrive sorted (thanks to secondary sort). A new session starts when the gap since the previous click is more than 30 minutes. How many sessions, and how long is each?

Input
8 click times (minutes since midnight)
Expected output
4 sessions: ['09:00+20m', '10:10+5m', '11:40+1m', '12:40+0m']

Approach

  1. Walk the sorted times once.
  2. Start a new session when gap > 30.
  3. Record the start and end of each session.

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.7 in one screen

  • Values in a reduce group are unordered; sorting them in reducer memory does not scale.
  • Composite key = natural key + secondary field (value-to-key conversion); compareTo sorts by both.
  • Partitioner on the natural key only; grouping comparator (setGroupingComparatorClass) on the natural key only.
  • Optional sort comparator (setSortComparatorClass) for descending or custom order.
  • Lab: 5 lakh orders → 2,000 groups and lines; S1413 first 09-01 14:18, last 09-30 16:30, gap 22 h. Without grouping: 490,794 groups.
  • The key object is reused: its secondary field changes as you iterate values.
  • Uses: first/last event, sessionization, top-N per group, joins with the small side first.

Interview and viva questions

What is secondary sort in Hadoop MapReduce?

A pattern that makes the values of each reduce group arrive in a chosen order. The mapper emits a composite key (natural key plus the field to sort by), the sort comparator orders by both, a custom partitioner uses only the natural key and a grouping comparator compares only the natural key, so each reduce() call receives one natural key with its values already sorted by the secondary field.

What is the difference between the sort comparator and the grouping comparator?

The sort comparator decides the order of all keys in the shuffle (by default the key's compareTo). The grouping comparator decides which consecutive sorted keys are passed to the same reduce() call. In secondary sort the sort comparator uses the whole composite key and the grouping comparator uses only the natural key.

Why do we need a custom partitioner for secondary sort?

The default HashPartitioner hashes the whole key. With a composite key, records of the same natural key but different secondary values could go to different reducers, so no reducer would see the complete group. A partitioner on the natural key keeps them together.

Can I just sort the values in the reducer instead?

Only if every group is small enough to fit in the reducer's heap. You must copy each value out of the reused object into a list, which costs memory proportional to the largest group. Secondary sort uses the framework's disk-backed sort and keeps memory constant.

How is secondary sort done in Spark or Hive?

Spark uses repartitionAndSortWithinPartitions with a partitioner on the natural key and an ordering on the composite key. Hive uses DISTRIBUTE BY natural_key SORT BY natural_key, field. SQL window functions with PARTITION BY ... ORDER BY express the same idea.

Practice problems

Secondary sort exercises, using the real numbers from the lab.

P1Easy

Groups vs students

With the grouping comparator, Reduce input groups was 2,000. Without it, 490,794. How many orders shared a (student, minute) key with another order?

Input
500,000 records · 490,794 groups
Expected output
9206 orders fell into an already existing (student, minute) group
Show solution and explanation
print(500000 - 490794, "orders fell into an already existing (student, minute) group")
9206 orders fell into an already existing (student, minute) group

How it works: 9,206 orders landed in a minute in which the same student had already ordered, like S0001's poha and thali at 08:15.

P2Easy

Average orders per student

How many orders does a student have on average, and how far is S0001 above that?

Input
500,000 orders · 2,000 students · S0001 3,899
Expected output
250.0 average; 15.6 x for S0001
Show solution and explanation
avg = 500000 / 2000
print(avg, "average;", round(3899 / avg, 1), "x for S0001")
250.0 average; 15.6 x for S0001

How it works: 250 on average; S0001 orders 15.6 times as much (one of the 20 heavy users in the generated data).

P3Easy

Sort the composite key

Sort these keys the way the shuffle does: student, then time.

Input
4 keys
Expected output
('S0001', '09-01 08:09')
('S0001', '09-01 08:15')
('S1413', '09-01 14:18')
('S1413', '09-30 16:30')
Show solution and explanation
keys = [("S1413", "09-30 16:30"), ("S0001", "09-01 08:15"), ("S1413", "09-01 14:18"), ("S0001", "09-01 08:09")]
for k in sorted(keys):
    print(k)
('S0001', '09-01 08:09') ('S0001', '09-01 08:15') ('S1413', '09-01 14:18') ('S1413', '09-30 16:30')

How it works: Python sorts tuples field by field, exactly like the compareTo in StudentTime.

P4Medium

Why the time format matters

Sort the same three times written as "d/m/yyyy H:MM" and as "yyyy-mm-dd HH:MM". Which gives the right order?

Input
3 times
Expected output
['1/9/2026 14:18', '14/9/2026 15:30', '30/8/2026 16:30']
['2026-08-30 16:30', '2026-09-01 14:18', '2026-09-14 15:30']
Show solution and explanation
bad = ["1/9/2026 14:18", "30/8/2026 16:30", "14/9/2026 15:30"]
good = ["2026-09-01 14:18", "2026-08-30 16:30", "2026-09-14 15:30"]
print(sorted(bad))
print(sorted(good))
['1/9/2026 14:18', '14/9/2026 15:30', '30/8/2026 16:30'] ['2026-08-30 16:30', '2026-09-01 14:18', '2026-09-14 15:30']

How it works: The first list puts 30 August last because text compares character by character. Text order matches time order only when the biggest unit comes first with fixed widths, as in our data.

P5Medium

Partition by natural key

With 3 reducers, assign each key to a reducer using only the student. Check that both S1413 keys land together.

Input
4 composite keys
Expected output
S1413 09-30 -> reducer 2
S0001 08:15 -> reducer 0
S1413 09-01 -> reducer 2
S0001 08:09 -> reducer 0
Show solution and explanation
keys = [("S1413", "09-30"), ("S0001", "08:15"), ("S1413", "09-01"), ("S0001", "08:09")]
for s, t in keys:
    print(s, t, "-> reducer", sum(map(ord, s)) % 3)
S1413 09-30 -> reducer 2 S0001 08:15 -> reducer 0 S1413 09-01 -> reducer 2 S0001 08:09 -> reducer 0

How it works: Both S1413 keys go to one reducer, both S0001 keys to another; the time plays no part.

P6Medium

First and last per group

Given sorted (student, time) records, print each student's first and last time in one pass.

Input
5 sorted records
Expected output
S0001 08:09 20:47
S1413 14:18 16:30
Show solution and explanation
recs = [("S0001", "08:09"), ("S0001", "08:15"), ("S0001", "20:47"), ("S1413", "14:18"), ("S1413", "16:30")]
cur, first, last = None, None, None
for s, t in recs + [(None, None)]:
    if s != cur:
        if cur:
            print(cur, first, last)
        cur, first = s, t
    last = t
S0001 08:09 20:47 S1413 14:18 16:30

How it works: A sentinel at the end flushes the last group, the same trick reducers in streaming (Lesson 9.8) use.

P7Business problem

Memory or redesign?

The reducer that sorts in memory needs 12 GB for its biggest user. Option A: raise reducer memory to 16 GB on 200 nodes (₹2,000 per node extra RAM per year). Option B: 2 developer-days (₹8,000 per day) to switch to secondary sort. Compare.

Input
₹2,000 × 200 vs 2 × ₹8,000
Expected output
A: Rs 400,000 per year, B: Rs 16,000 once
Show solution and explanation
a, b = 2000 * 200, 2 * 8000
print(f"A: Rs {a:,} per year, B: Rs {b:,} once")
A: Rs 400,000 per year, B: Rs 16,000 once

How it works: ₹4 lakh a year vs ₹16,000 once (illustrative), and A breaks again when the biggest user grows.

P8Advanced

Top-2 per counter

For each canteen counter, list its top 2 items by revenue, using sort by (counter, revenue descending) and taking the first 2 values per group.

Input
8 items with counter and revenue
Expected output
A chai 2245035
A coffee 807280
B vada pav 1395080
B samosa 1257912
C idli 1343220
C poha 1133400
D thali 2423360
D dosa 2246715
Show solution and explanation
rows = [("A", "chai", 2245035), ("A", "coffee", 807280), ("B", "samosa", 1257912), ("B", "vada pav", 1395080),
        ("C", "poha", 1133400), ("C", "idli", 1343220), ("D", "dosa", 2246715), ("D", "thali", 2423360)]
rows.sort(key=lambda r: (r[0], -r[2]))
taken = {}
for counter, item, rev in rows:
    if taken.get(counter, 0) < 2:
        print(counter, item, rev)
        taken[counter] = taken.get(counter, 0) + 1
A chai 2245035 A coffee 807280 B vada pav 1395080 B samosa 1257912 C idli 1343220 C poha 1133400 D thali 2423360 D dosa 2246715

How it works: Sorting by negative revenue gives descending order inside each counter group; the reducer just counts to N.

Real-world scenario questions (asked in company interviews)

Secondary sort is a favourite question for experienced Hadoop and Spark engineers.

Concepts · basicsFresher

“Are reducer values sorted in MapReduce?”

A quick check question.

The interviewer is checking: keys vs values.

Show a strong answer
  • Keys are sorted by the shuffle; values for one key are not in any guaranteed order.
  • To get ordered values, use secondary sort: composite key, partitioner and grouping comparator on the natural key.
Design · patternExperienced

Each customer's transactions must be processed in time order

Some customers have crores of transactions; the reducer heap is 4 GB.

The interviewer is checking: all three pieces and why.

Show a strong answer
  • Map emits key (customer, timestamp), value = transaction.
  • Partitioner and grouping comparator on customer only; sort comparator on (customer, timestamp).
  • The reducer streams each customer's transactions in order with constant memory; verify with Reduce input groups = number of customers.
Debugging · outputExperienced

A sessionization job's output has 50 times more rows than users

It ran successfully; the numbers look wrong.

The interviewer is checking: recognising a missing grouping comparator.

Show a strong answer
  • Check Reduce input groups: if it is near the number of (user, timestamp) pairs, the grouping comparator is missing or wrong.
  • Set job.setGroupingComparatorClass with a comparator on the user only.
  • Also check the partitioner: wrong results appear with 2+ reducers if it hashes the whole key.
Internals · reuseExperienced

“Inside the loop over values, can I read the time from the key?”

A follow-up after you explain secondary sort.

The interviewer is checking: object reuse.

Show a strong answer
  • Yes: the framework deserializes each record's key into the same key object, so key.time changes on each step.
  • But don't keep references to the key or values; copy the fields you need.
  • Putting the field in the value too (as our job does) makes the code clearer.