•CASE STUDY

Sorting a 500 GB CSV with 16 GB of RAM (External Merge Sort)

5 min read·886 words·Intermediate

Asked at

1 candidate report in Jun 2026

How to use this case study

SDE-2 / Mid

  • Explain splitting the file into sorted runs that fit in memory
  • Then k-way merging them with a min-heap

SDE-3 / Senior

  • Calculate the number of runs and merge passes
  • Choose buffer sizes
  • Handle CSV parsing (quoted commas) and stable sorting

Staff / Principal

  • Discuss parallelizing across cores and machines (range partitioning with sampling)
  • I/O vs CPU bottlenecks
  • Failure recovery

Problem RestatementProblem

Microsoft asked: sort a 500 GB CSV file by one column, on a machine with 16 GB of RAM. The data doesn't fit in memory, so a normal sort won't work. We need an external sort: sort pieces in memory, save them to disk, then merge them.

Phase 1: Create Sorted Runs

  1. Read the file in chunks of ~12 GB (leave room for overhead and the OS).
  2. Parse each row, sort the chunk in memory by the key column, and write it to disk as a run (a sorted file).
  3. 500 GB / 12 GB ≈ 42 runs.

Tips:

  • Parse CSV properly: fields can contain quoted commas and newlines, so use a real CSV parser, not split(",").
  • Convert the key to the right type (number vs string vs date) so "10" sorts after "9" when numeric.
  • For a stable sort (keep original order for equal keys), include the original row number as a tie-breaker.

Phase 2: K-Way Merge

  • Open all 42 runs and read a buffer from each (e.g., 100 MB × 42 ≈ 4 GB).
  • Put the first row of each run into a min-heap keyed by the sort column.
  • Repeatedly pop the smallest row, write it to the output buffer, and push the next row from the same run. Refill a run's buffer when it empties.
  • Write the output in big sequential blocks.

import heapq, csv

def merge_runs(run_paths, out_path, key_index, key_type=str):
    files = [open(p, newline='') for p in run_paths]
    readers = [csv.reader(f) for f in files]
    heap = []
    for i, r in enumerate(readers):
        row = next(r, None)
        if row: heapq.heappush(heap, (key_type(row[key_index]), i, row))
    with open(out_path, 'w', newline='') as out:
        w = csv.writer(out)
        while heap:
            _, i, row = heapq.heappop(heap)
            w.writerow(row)
            nxt = next(readers[i], None)
            if nxt: heapq.heappush(heap, (key_type(nxt[key_index]), i, nxt))
    for f in files: f.close()

The run index i in the tuple also acts as a tie-breaker, and it keeps the merge stable across runs.

Architecture diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
    IN[("500 GB CSV")] --> C1["Chunk 12 GB - sort in RAM"]
    IN --> C2["Chunk 12 GB - sort in RAM"]
    IN --> C3["... 42 chunks"]
    C1 --> R1[("Run 1")]
    C2 --> R2[("Run 2")]
    C3 --> RN[("Run 42")]
    R1 --> M["K-way merge - min-heap"]
    R2 --> M
    RN --> M
    M --> OUT[("Sorted 500 GB output")]

Deep Dive — How many times do you read 500 GB?Deep dive

Both phases are I/O bound, so the design goal is to minimise the number of passes over the data. Each pass is 500 GB read plus 500 GB written.

Weak

Merge two runs at a time

Produce sorted runs, then merge them pairwise: 42 runs become 21, then 11, then 6, 3, 2, 1.

Architecture diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
  R["42 sorted runs"] --> P1["Pass 1: 42 -> 21"]
  P1 --> P2["Pass 2: 21 -> 11"]
  P2 --> P3["Passes 3, 4, 5, 6"]
  P3 --> TOT["6 merge passes"]
  TOT --> IO["About 7 x 2 x 500 GB = 7 TB of I/O"]

Pairwise merging uses almost none of the available memory — two input buffers — and pays for it with log2(runs) passes. The machine has 16 GB and is using a few megabytes of it.

Good

Merge all the runs at once

Give each run its own read buffer and merge k-way with a heap. With 42 runs and 16 GB of RAM there is room for 42 buffers of several hundred megabytes, so one merge pass finishes the job.

total I/O ≈ (1 + 1) × 2 × 500 GB = 2 TB

That is the answer for this problem, and it is worth stating the arithmetic out loud. It stops working when runs outnumber the buffers memory can hold — with a few thousand runs, buffers become too small for efficient sequential reads and you are back to multiple rounds.

Best

Make the runs bigger so the fan-in fits

The number of passes is set by the number of runs, and the number of runs is set by how large each one is. So attack run size:

Architecture diagram
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
  IN["500 GB input"] --> RS["Phase 1 - build sorted runs"]
  RS --> NORM["Load-sort-write: runs about the size of memory"]
  RS --> REPL["Replacement selection: runs about 2x memory on average"]
  REPL --> FEWER["Roughly half as many runs"]
  FEWER --> ONE["One merge pass, with large read buffers"]
  ONE --> OUT["Sorted output - 2 TB total I/O"]
  • Replacement selection keeps a heap of memory-size and streams records through it, emitting the smallest record still greater than the last one written. Because incoming records often still qualify for the current run, runs come out around twice the size of memory on average — halving the run count for free.
  • Size read buffers for sequential throughput. A k-way merge with buffers too small turns a sequential read into seeks; there is a point where fewer, larger buffers over more passes beats more, smaller buffers in one.
  • Compress the runs if CPU is spare. The passes are I/O bound, so trading CPU for bytes on disk shortens every pass.

The rule underneath: passes ≈ 1 + ceil(log_k(runs)), where k is how many runs you can buffer at once. Every improvement here is either raising k or lowering the run count.

PerformanceScale

  • The job is usually I/O-bound: 500 GB read + written twice ≈ 2 TB of I/O. At 500 MB/s that's ~70 minutes. On spinning disks, keep I/O sequential with large buffers.
  • Put runs and output on a different disk than the input if possible.
  • Use compression for runs (fast codecs like LZ4) if the CPU is idle and disk is the bottleneck.
  • Parse and sort chunks in parallel on multiple cores while another thread reads the next chunk.

Scaling to Many Machines

  • Sample the key column to find split points (e.g., 99 cut-offs for 100 machines), then range-partition: each machine gets the rows in its key range, sorts them locally (externally if needed), and outputs its part. Concatenating the parts in order gives the full sorted file (as TeraSort does).
  • Fault tolerance: runs are files, so a crashed step can restart from the completed runs.

Wrap-UpWrap-up

Read the file in chunks that fit in memory, sort each by the key (parsing CSV correctly, typed keys, row-number tie-breakers) and write sorted runs. Then k-way merge the runs with a min-heap and large sequential buffers, which takes one pass for ~42 runs. Keep I/O sequential, overlap reading and sorting, and for more scale range-partition by sampled key split points across machines.

More Case Studies

Frequently Asked Questions

What is the Sorting a 500 GB CSV with 16 GB of RAM (External Merge Sort) system design question?

Sorting a 500 GB CSV with 16 GB of RAM (External Merge Sort) is a system design interview question asked at FAANG companies. It covers algorithms, storage, data pipelines and tests your ability to design scalable, production-ready systems. InterviewSkool's breakdown walks you through requirements, API design, architecture, and trade-offs.

Which companies ask the Sorting a 500 GB CSV with 16 GB of RAM (External Merge Sort) question?

Microsoft have reportedly asked variations of this question in system design interviews. The exact wording may differ, but the core design challenges remain the same.

How should I prepare for the Sorting a 500 GB CSV with 16 GB of RAM (External Merge Sort) interview question?

Start with the problem statement and scale estimates, then design the high-level architecture. Focus on the core components, data model, and API design. InterviewSkool's breakdown covers the full solution with mermaid diagrams and trade-off analysis to help you prep efficiently.

What level is the Sorting a 500 GB CSV with 16 GB of RAM (External Merge Sort) question?

This question is suitable for SDE-2, SDE-3, and Staff engineer interviews. The level guidance on this page provides specific tips for each level — SDE-2 candidates should focus on core architecture, while Staff engineers should discuss trade-offs, monitoring, and incremental rollouts.

Practice with a Mock Interview

Apply what you learned in a live system design mock interview with InterviewSkool's AI interviewer.

Start System Design Interview →