BeginnerSpark · 13 min · 16 challenges

Partitioning: Beginner

Take the Netflix job that computes yesterday's top 10 titles: it reads a day of playback events and usually finishes in 20 minutes. This Friday it is at 3 hours and climbing. The bytes have not changed, but their shape has: 400,000 files of 300KB instead of 1,000 files of 128MB, and the job is paying for every one of them.

What a Partition Is

Daily Life
Interviews
A partition is the unit of work in Spark: a slice of a DataFrame that one task reads and transforms on one core, with no help from any other task. On disk, a partition is a byte range: a whole file, or a piece of a file, with a start offset and a length. In memory, it's the block of rows that one executor holds for that slice. Every narrow transformation runs partition by partition, so a filter keeps the exact count it was given, and so does a select or a withColumn.
Two numbers describe any partition: how many bytes it holds, and how many rows that becomes once decoded. The first decides how long its task spends reading. The second decides how much executor memory the task needs while it works. Both come back in the sections that follow, because every partitioning problem is one of those 2 numbers sitting far from where it should be.

Where partitions come from on read

When you call spark.read on a directory, Spark lists the files and cuts them into partitions of one split size. spark.sql.files.maxPartitionBytes caps the split at 128MB by default. The split shrinks when a read is small so that every core still gets work: Spark divides the total bytes, plus a 4MB charge per file, by the core count and takes that when it's smaller, with 4MB as the floor. Any read above 128MB times the core count splits at 128MB: a 10GB file on 16 cores becomes 80 partitions, read by 80 tasks in parallel. Files larger than the split are cut at its boundaries, and smaller files are packed together up to the split.

On disk, read on 16 coresPartitionsEach partition holds
1 file of 10GB80128MB
1 file of 300MB1615 of 19MB, 1 of 15MB
1 file of 50MB1312 of 4MB, 1 of 2MB
10 files of 6MB101 whole file
The count depends only on file sizes and the core count, so you can predict it from a directory listing and the cluster size before a single row is read. That matters because the count is also the number of tasks the first stage launches, and nothing in a narrow transformation changes it.
orders.filter(F.col("status") == "refunded").collect()
orders12 rows
order_id
status
amount
1
shipped
40
2
shipped
25
3
refunded
60
4
shipped
35
5
shipped
50
6
shipped
20
7
shipped
45
8
shipped
30
9
refunded
15
10
shipped
55
11
shipped
70
12
shipped
65
Preparing the animation

The program above reads a 12-row orders table as 4 partitions and filters it down to the 2 refunded rows. Follow the task count: 4 tasks launch, each scans its 3 rows, and 4 result sets travel back to the driver even though at least 2 of them carry nothing. The filter removed 83% of the rows and left the partition count alone.

Why 128MB

Each task carries a fixed cost: the driver serializes and schedules it, an executor opens the input, and the result is reported back, a few milliseconds of overhead whatever the size of the input. Reading and decoding 128MB takes a task on the order of a second or more, so at that size the overhead stays under 1% of the work. Memory sets the upper limit. A task decodes its partition into rows in memory, and the decoded rows take more space than the bytes on disk, so a 128MB input expands into something that still fits inside the memory one task gets, where a 2GB input often does not. At 128MB a read also leaves enough partitions to spread the work: a 10GB read gives 80 tasks, which keep a 16-core cluster busy for 5 rounds, so one slow task shifts the finish line by a fraction of a round rather than doubling it.
2 to 3
tasks per core, per the tuning guide
200ms
shortest task Spark schedules well
Both figures come from Spark's own tuning guide, the first so that every core always has a queue of work waiting, the second as the floor below which scheduling dominates. A partition of 128MB on splittable input lands well above that floor and well below the point where a single task outgrows its memory.
Multiple Choice

A directory holds one 2GB CSV file and nothing else. On a 16-core cluster, you call spark.read.option("header", "true").csv(path) with default settings. How many partitions does the first stage read, and what does each hold?

Too Many Small Partitions

Daily Life
Interviews

A job drowns in small partitions when the count stays high while the bytes per partition collapse, and you have already met the usual sources. A selective filter keeps the partition count: 80 partitions of 128MB that keep 0.1% of their rows become 80 partitions of around 128KB, and every narrow step after it runs as 80 tasks over 10MB of data. A shuffle writes spark.sql.shuffle.partitions partitions, 200 by default, whatever the data: a groupBy over 50MB writes 200 shuffle partitions of roughly 250KB each. Adaptive query execution, on by default since Spark 3.2 and taught later, merges small shuffle partitions at run time, so all 200 run only when it is off. And a directory of thousands of tiny files produces thousands of tiny partitions, which the small-files section prices in detail.

What a task costs before it does any work

Every task pays the same entrance fee. The driver serializes the task, finds a free slot, and sends it over the network. On arrival the executor deserializes it and opens its input before setting up the code it will run. When the task finishes it reports its status and metrics back, and the driver records them and schedules the next one. None of these steps looks at your data, and together they take a few milliseconds to a few tens of milliseconds per task. That fee is noise next to a 128MB partition and the whole bill for a 50KB one.
Same 1GB of input8 partitions20,000 partitions
Bytes per task128MBabout 50KB
Work per taskabout 1 secondunder 1 millisecond
Fixed cost per taskabout 10msabout 10ms
Share of time that is overheadabout 1%over 90%
Tasks the driver must schedule820,000
Read the last row again. The driver works through its task queue from one scheduling loop, so 20,000 tasks is 20,000 trips through that loop, and the executors spend most of each slot's time waiting for the next assignment to arrive. The same bytes in 8 partitions ask the driver for 8 decisions.

Empty reducers after a shuffle

spark.conf.set("spark.sql.shuffle.partitions", 6)
orders.groupBy("region").agg(F.sum("amount").alias("total")).collect()
orders9 rows
order_id
region
amount
1
East
40
2
West
25
3
North
60
4
East
35
5
West
50
6
North
20
7
East
45
8
West
30
9
North
15
Preparing the animation
Here 9 orders in 3 regions are summed by region with the shuffle partition count set to 6. The map side runs 3 tasks, one per input partition, and each hashes its rows by region. The reduce side launches 6 tasks for 3 keys, so at least 3 of them receive no rows at all; they run anyway and return an empty result. East sums to 120. West and North come to 105 and 95, and at least half of the reduce tasks contributed nothing but overhead. At the default of 200, at least 197 partitions would hold nothing, and with adaptive execution off each would still run as a task.

What it looks like in the Spark UI

A stage with thousands of tasks over a few megabytes of input
A stage with thousands of tasks over a few megabytes of input
Task durations where the median sits below 50ms
Task durations where the median sits below 50ms
Input size per task measured in KB rather than MB
Input size per task measured in KB rather than MB
Scheduler delay that is a visible share of each task's time
Scheduler delay that is a visible share of each task's time
Exactly 200 tasks after every groupBy or join, a sign adaptive execution is off
Exactly 200 tasks after every groupBy or join, a sign adaptive execution is off

The runnable example below groups the practice orders table by status and counts each group. Run it: the shuffle writes 200 partitions for a handful of statuses, and adaptive execution merges the near-empty ones, so on a cluster the reduce stage runs as around 1 task. With it off, 200 tasks would produce those few rows. The isNotNull filter keeps a null status from forming its own group.

status_counts = (
    orders.filter(F.col("status").isNotNull())
    .groupBy("status")
    .count()
)
status_counts.show()
TIP
Exactly 200 tasks in a reduce stage over a small input means the shuffle default set the count and adaptive execution is off. The methods that change it arrive in the next lesson.
Multiple Choice

A nightly job reads 400 partitions of 128MB, filters to the 0.05% of rows that are errors, then runs 6 more narrow transformations and writes. The Spark UI shows 400 tasks in that stage with a median duration of 30ms. What's the job paying for?

Too Few Large Partitions

Daily Life
Interviews
The opposite failure is quieter and costs more per hour. A job with fewer partitions than cores leaves the extra cores with nothing to run. Take 64GB read as 8 partitions on a cluster with 64 cores: 8 tasks start, each with 8GB to work through, and the other cores sit idle for the whole stage. The stage takes as long as one task needs to read 8GB, which is 64 times longer than a 128MB task would take, while you pay for 64 cores and 8 of them work.
56
cores idle for the whole stage
12.5%
of the cluster doing work
Idle cores are the part that shows on the bill. A cluster rented by the hour costs the same per minute whether 8 tasks or 64 are running, and the 8-task version runs many times longer. The part that doesn't show until it fails is what each of those 8 tasks has to hold in memory.

Where too few partitions come from

Unsplittable files are the classic source. A CSV compressed with gzip cannot be cut at an arbitrary byte, because decompression has to start from the beginning of the stream, so a single 10GB .csv.gz arrives as 1 partition and 1 task however many cores you have. A read from a relational database through JDBC is 1 partition unless you tell Spark how to split the query, so a 200 million row table arrives through a single connection into a single task. And a shuffle partition setting that someone lowered to 4 for a small table keeps applying to every groupBy and join in that session, including the one over the big table.

64GB of input8 partitions512 partitions
Bytes per task8GB128MB
Cores busy on a 64-core cluster864
Rounds of tasks18
Memory a task must holdseveral GB of decoded rowsa few hundred MB
If 1 task is slowthe stage waits for itthe other 63 cores keep working

Spill, then OOM

Each task gets a bounded share of its executor's memory. A task that sorts or aggregates its partition has to hold the working set inside that share, and an 8GB partition decoded into rows will not fit. Spark's first answer is to spill: it writes part of the working set to the executor's local disk and reads that part back when the task needs it, so the task can keep going. Every spilled byte is written once and read once more, on a disk far slower than memory, so a task that spills 6GB has added 12GB of disk traffic to its own work. Spill is slow and survivable. When even the part that must stay resident outgrows the share, a hash table for a group holding millions of rows for instance, the executor throws an out of memory error and dies. Its tasks are retried on another executor, which has the same memory and the same 8GB partition, and dies the same way.

The tells in the Spark UI

A stage with fewer tasks than the cluster has cores
A stage with fewer tasks than the cluster has cores
Task durations in minutes while most executors show no active tasks
Task durations in minutes while most executors show no active tasks
Nonzero values in the Spill (Memory) and Spill (Disk) columns
Nonzero values in the Spill (Memory) and Spill (Disk) columns
A failure reason that names OutOfMemoryError or a lost executor
A failure reason that names OutOfMemoryError or a lost executor
Input per task in GB rather than MB
Input per task in GB rather than MB
✓Too many small partitions
  • Thousands of tasks over few bytes
  • Overhead outweighs work
  • Driver busy scheduling
  • Cure: lower the count
✗Too few large partitions
  • Fewer tasks than cores
  • Idle cores, long tasks
  • Spill, then OOM
  • Cure: raise the count
Both failures look the same on the wall clock: the stage is slow. You read both off the same screen by comparing the task count against the core count, and the bytes per task against 128MB. With those 2 numbers you know which direction to move the count, before you know the tool that moves it.
Multiple Choice

A job reads a single 12GB gzip-compressed CSV on a cluster with 48 cores, then groups by customer and writes. The first stage shows 1 task that ran for 40 minutes, and the second stage shows 200 tasks that finished in under a minute. Where did the time go, and why?

The Small-Files Problem

Daily Life
Interviews
Take 100GB of data written 2 ways: 1,000 files of 100MB, or 100,000 files of 1MB. The bytes and the rows are identical, yet a job that reads them does very different amounts of work. The difference is a set of costs that scale with the number of files rather than the number of bytes, and at 100,000 files those costs stop being a rounding error.

Three costs that scale with file count

Spark has to list the files before it can plan a read: it needs the name and size of every file, and it gets them from the storage system in pages. An object store returns at most 1,000 names per list request, so 1,000 files arrive in 1 round trip and 100,000 files take 100 of them, each a network call the driver waits on in turn. The driver then holds an entry for every file in memory while it plans; 100,000 entries is a visible slice of a small driver's heap, and a table that keeps growing eventually makes the driver the thing that runs out of memory.
Then every file has to be opened. Each file is a separate object, and reading its first byte means a separate request: authenticate, locate, open, then stream. On an object store that round trip costs tens of milliseconds before any data flows. One file of 100MB pays it once and then streams for a second or more. Reading 100 files of 1MB pays it 100 times for the same bytes, and the reading task spends more time waiting for opens than reading data.

The open cost also sets the partition count. Spark packs small files into partitions, but it charges each file a fixed spark.sql.files.openCostInBytes of 4MB on top of its real size when it fills a 128MB partition, precisely because opening is expensive. A 1MB file counts as 5MB, so a partition holds about 25 of them and about 25MB of real data. The 100,000 files become roughly 4,000 partitions and as many tasks, each reading about 25MB through around 25 opens, where the 1,000 large files become 1,000 tasks reading 100MB through 1 open each.

100GB on disk1,000 files of 100MB100,000 files of 1MB
List requests (1,000 names each)1100
File entries held by the driver1,000100,000
Open requests1,000100,000
Partitions in the read1,000about 4,000
Real data per task100MBabout 25MB
Opens per task1about 25

Where 100,000 files come from

Files are born one per task. When a DataFrame is written, every partition of its final stage becomes 1 file in the output directory, so the file count of a write equals the partition count of the stage that wrote it. A groupBy as the last step shuffles into 200 partitions by default, and unless adaptive execution merges them, the job writes 200 files however small the result. Run that job every hour and the directory gains 4,800 files a day and 1,752,000 a year, almost all of them far below 1MB. Merging shrinks each run's count, but every run still adds files.
event stream
Order events
Spark
Hourly aggregate
PARTITIONS200SLAevery hour
object store
Hourly totals
Spark
Nightly report

With adaptive execution off, each hourly run writes 200 files, one per partition of its last stage. The totals directory gains 4,800 files a day, and the nightly report lists and opens every one of them.

Write fewer, larger files

The cure is to write fewer, larger files, aiming at 128MB to 1GB each, so that a reader gets 1 open per 128MB of data and a partition count that matches the bytes. That means controlling the partition count of the writing stage, and the methods that set it are the subject of the next lesson. What this lesson gives you is the diagnosis: when a read of a modest table takes minutes before its first task starts, or its first stage has 10 times the tasks the byte count predicts, count the files.
Multiple Choice

A table is written by a job whose last stage has 200 partitions. The job runs once an hour, and each run writes to a new subdirectory. After 30 days, how many files does a full read have to list and open, and what set that number?

The Cost of inferSchema

Daily Life
Interviews
A CSV file carries no types. Every value is text, and the only metadata is an optional header line of column names. When you read one, every column still needs a type. Spark can leave every column as string or use a schema you declare, and neither costs anything extra, or it can infer the types, which costs a full extra pass over the data.
raw = spark.read.option("header", "true").csv("/lake/orders/")
inferred = (
spark.read.option("header", "true")
.option("inferSchema", "true")
.csv("/lake/orders/")
)

With inferSchema set, Spark parses every row of every file and, for each column, tracks the narrowest type that still fits every value seen so far: integer until a decimal point appears, double until a letter appears, string after that. That's a complete scan of the input, and it runs when .csv(path) is called, before you have asked for anything. You'll see a job appear in the UI with no action in your code. When you do call an action, the real job reads the same bytes a second time. On a 500GB directory you have read 1TB to get an answer that needed 500GB.

500GB of CSVScanned to build the DataFrameScanned by the first actionTotal
No options1 header line500GB500GB
inferSchema true500GB500GB1TB
Declared schema0500GB500GB
2x
bytes read with inference on
1 line
read when every column is a string

Declaring the schema

orders_schema = "order_id INT, status STRING, region STRING, profit DOUBLE"
orders = (
spark.read.option("header", "true")
.schema(orders_schema)
.csv("/lake/orders/")
)

The schema string names each column and its type in the same form you would use in a CREATE TABLE. With it in place, the read opens nothing until an action runs, and then the parser converts each value straight into the declared type. The same schema can be built as a StructType of StructField objects, which you reach for when a field is nested. Either way the cost of learning the types drops to 0 bytes, and the types are the ones you chose rather than the ones a scan happened to find.

Inference also guesses

The extra scan would be tolerable if it always produced the right schema. It produces the schema that fits the data on that day. A postal code column holding 02134 infers as an integer and comes back as 2134, with the leading zero gone for good. An amount column that is numeric in a billion rows and holds the text N/A in 1 row infers as string, and the arithmetic you write downstream runs on a string column: depending on your settings it either fails at the action, after the job has run for an hour, or quietly turns the bad values into null. A column that is empty in every row infers as string, and the day it fills with numbers the schema changes under every job that reads it. A declared schema keeps the types you chose: a value that doesn't parse becomes null by default, and .option("mode", "FAILFAST") makes the read fail at the first malformed row.

JSON reads pay the same pass. spark.read.json(path) infers by default, so a JSON read without .schema(...) always pays the full pass. Both readers accept a samplingRatio option that infers from a fraction of the rows, which shortens the scan and makes the guess less reliable at the same time; a declared schema avoids both problems.

TIP
In a notebook, read a small sample with inferSchema and paste the printed schema into a declaration. Inference is a fine way to learn a source once, and a poor way to read it every night.
✓Do
  • Declare the schema for every CSV and JSON read in production
  • Keep the schema next to the job so a change to it is reviewed
  • Infer once on a sample, then paste the result as the declaration
  • Set mode to FAILFAST so a malformed row stops the read and sends you to the source
✗Don't
  • Pay a 500GB scan to learn 6 column types
  • Let today's data decide tomorrow's types
  • Store postal codes or IDs with leading zeros as inferred integers
  • Expect a sampled inference to see the 1 row that breaks the type
Multiple Choice

A job reads a 2TB CSV directory with inferSchema set to true, filters to 1 region, and calls count(). Roughly how many bytes does the job read in total, and when does the first scan happen?

❯❯❯PUTTING IT ALL TOGETHER

> A team at Spotify computes daily podcast listening totals. An hourly job aggregates play events with a groupBy, runs with adaptive execution switched off, and writes 200 files per run into one directory. A nightly job reads that directory, joins it to a 300GB gzip-compressed CSV export of show metadata read with inferSchema, and groups by show, in a session whose shuffle partition count was lowered to 8 back when the export was small. The nightly run has gone from 12 minutes to 2 hours over 6 months, and last week one executor died with an out of memory error.

The directory now holds 200 times 24 times 180, about 864,000 files, so the nightly read spends its first 10 minutes on 864 list requests and then opens every file: the small-files problem, caused by the writing stage's 200 partitions on every run.
The read packs those tiny files roughly 25 to a partition, giving tens of thousands of tasks with 30ms medians: too many small partitions, visible as a stage drowning in scheduler delay.
The 300GB CSV is scanned twice, once at read time for inference and once for the join, so 600GB of the nightly bill buys 6 column types a declared schema would have given for free.
The metadata export is 1 gzip file, so its read is 1 task on 1 core while the rest of the cluster waits: too few large partitions at the read. That task streams its rows on to the join's shuffle and holds little of the 300GB itself.
The OOM comes from the join: the leftover shuffle partition count of 8 splits the 300GB into 8 partitions of more than 37GB each, a working set far beyond one task's memory share. Tasks spill first, and then an executor dies.
Every one of these is read off the same 2 numbers in the Spark UI, task count against core count and bytes per task against 128MB, before any tuning tool is picked up.
KEY TAKEAWAYS
A partition is a byte range on disk and a block of rows in memory, read by exactly 1 task; narrow transformations keep its count.
128MB is the largest split by default and the size to aim for: large enough that task overhead stays under 1%, small enough to fit a task's memory and leave enough tasks to spread.
Too many small partitions shows as thousands of sub-50ms tasks over kilobytes each; the entrance fee of each task outweighs its work.
Too few large partitions shows as fewer tasks than cores, which leaves executors idle, while the tasks that do run fill the spill columns and kill executors with OOM.
File count is a cost of its own: Spark has to list and open every file and charges 4MB per open, which turns 100,000 files of 1MB into around 4,000 tasks and 100,000 round trips.
inferSchema is a full scan that runs before your first action; a declared schema costs 0 bytes and gives you the types you meant.

Partitions decide how many tasks run and how much each one carries; here is what the right size is and what going wrong looks like.

Category
Spark
Difficulty
beginner
Duration
13 minutes
Challenges
16 hands-on challenges

Topics covered: What a Partition Is, Too Many Small Partitions, Too Few Large Partitions, The Small-Files Problem, The Cost of inferSchema

Lesson Sections

  1. What a Partition Is (concepts: Distributed Primitives)

    A partition is the unit of work in Spark: a slice of a DataFrame that one task reads and transforms on one core, with no help from any other task. On disk, a partition is a byte range: a whole file, or a piece of a file, with a start offset and a length. In memory, it's the block of rows that one executor holds for that slice. Every narrow transformation runs partition by partition, so a filter keeps the exact count it was given, and so does a select or a withColumn. Two numbers describe any par

  2. Too Many Small Partitions

    What a task costs before it does any work Every task pays the same entrance fee. The driver serializes the task, finds a free slot, and sends it over the network. On arrival the executor deserializes it and opens its input before setting up the code it will run. When the task finishes it reports its status and metrics back, and the driver records them and schedules the next one. None of these steps looks at your data, and together they take a few milliseconds to a few tens of milliseconds per ta

  3. Too Few Large Partitions

    The opposite failure is quieter and costs more per hour. A job with fewer partitions than cores leaves the extra cores with nothing to run. Take 64GB read as 8 partitions on a cluster with 64 cores: 8 tasks start, each with 8GB to work through, and the other cores sit idle for the whole stage. The stage takes as long as one task needs to read 8GB, which is 64 times longer than a 128MB task would take, while you pay for 64 cores and 8 of them work. Idle cores are the part that shows on the bill.

  4. The Small-Files Problem

    Take 100GB of data written 2 ways: 1,000 files of 100MB, or 100,000 files of 1MB. The bytes and the rows are identical, yet a job that reads them does very different amounts of work. The difference is a set of costs that scale with the number of files rather than the number of bytes, and at 100,000 files those costs stop being a rounding error. Three costs that scale with file count Spark has to list the files before it can plan a read: it needs the name and size of every file, and it gets them

  5. The Cost of inferSchema (concepts: Schema Definition)

    A CSV file carries no types. Every value is text, and the only metadata is an optional header line of column names. When you read one, every column still needs a type. Spark can leave every column as string or use a schema you declare, and neither costs anything extra, or it can infer the types, which costs a full extra pass over the data. Declaring the schema Inference also guesses