Partitioning: Beginner
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 cores | Partitions | Each partition holds |
|---|---|---|
| 1 file of 10GB | 80 | 128MB |
| 1 file of 300MB | 16 | 15 of 19MB, 1 of 15MB |
| 1 file of 50MB | 13 | 12 of 4MB, 1 of 2MB |
| 10 files of 6MB | 10 | 1 whole file |
orders.filter(F.col("status") == "refunded").collect()
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
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?
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
| Same 1GB of input | 8 partitions | 20,000 partitions |
|---|---|---|
| Bytes per task | 128MB | about 50KB |
| Work per task | about 1 second | under 1 millisecond |
| Fixed cost per task | about 10ms | about 10ms |
| Share of time that is overhead | about 1% | over 90% |
| Tasks the driver must schedule | 8 | 20,000 |
Empty reducers after a shuffle
spark.conf.set("spark.sql.shuffle.partitions", 6)orders.groupBy("region").agg(F.sum("amount").alias("total")).collect()
What it looks like in the Spark UI
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()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?
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 input | 8 partitions | 512 partitions |
|---|---|---|
| Bytes per task | 8GB | 128MB |
| Cores busy on a 64-core cluster | 8 | 64 |
| Rounds of tasks | 1 | 8 |
| Memory a task must hold | several GB of decoded rows | a few hundred MB |
| If 1 task is slow | the stage waits for it | the other 63 cores keep working |
Spill, then OOM
The tells in the Spark UI
- Thousands of tasks over few bytes
- Overhead outweighs work
- Driver busy scheduling
- Cure: lower the count
- Fewer tasks than cores
- Idle cores, long tasks
- Spill, then OOM
- Cure: raise the count
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?
Three costs that scale with file count
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 disk | 1,000 files of 100MB | 100,000 files of 1MB |
|---|---|---|
| List requests (1,000 names each) | 1 | 100 |
| File entries held by the driver | 1,000 | 100,000 |
| Open requests | 1,000 | 100,000 |
| Partitions in the read | 1,000 | about 4,000 |
| Real data per task | 100MB | about 25MB |
| Opens per task | 1 | about 25 |
Where 100,000 files come from
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
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?
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 CSV | Scanned to build the DataFrame | Scanned by the first action | Total |
|---|---|---|---|
| No options | 1 header line | 500GB | 500GB |
| inferSchema true | 500GB | 500GB | 1TB |
| Declared schema | 0 | 500GB | 500GB |
Declaring the schema
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.
- 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
- 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
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?
> 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.
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
- 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
- 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
- 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.
- 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
- 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