my first two articles on this sequence, you’ve already lined a whole lot of floor.
You know the way to create a SparkSession, load information right into a DataFrame, outline schemas, clear messy columns, be part of datasets, write Parquet information, and construct easy PySpark workflows. I’ll put hyperlinks to these articles on this sequence on the finish of this one.
That’s already sufficient to do helpful work. However as soon as your information grows or your transformations develop into extra advanced, a brand new set of questions begins to seem.
- Why did this job abruptly develop into gradual?
- Why does a easy groupBy() take longer than anticipated?
- Why does PySpark write so many output information?
- When do you have to cache a DataFrame?
- And what on earth is a shuffle?
This text is about these subsequent steps.
We’re nonetheless conserving issues pleasant. We’re not going deep into cluster tuning, reminiscence internals, Kubernetes, YARN, or obscure PySpark configuration settings. As a substitute, we’ll give attention to sensible concepts that aid you perceive what PySpark does and how you can write PySpark code that behaves extra predictably.
The overarching theme is that this:
Attaining intermediate stage PySpark is usually about understanding information motion.
As soon as that concept clicks, PySpark begins to really feel much more predictable. There’s normally motive why your PySpark jobs are underperforming; you simply have to know the place to look.
When PySpark begins to really feel gradual
While you first study PySpark, it’s pure to give attention to the code.
You write issues like:
df.filter(df.metropolis == "London")
df.groupBy("customer_id").sum("quantity")
df.be part of(clients, on="customer_id")
However as you progress into extra superior PySpark, it’s essential begin enthusiastic about what PySpark has to do behind the scenes.
Some operations are pretty low cost. Filtering rows is an effective instance. Spark can take a look at every row, make a sure/no determination, and transfer on.
df_london = df.filter(df.metropolis == "London")
Different operations are dearer as a result of PySpark has to maneuver information round. Take into account this. Just like the filter, it’s only one line of code, however PySpark has to verify all rows for a similar buyer find yourself collectively.
df_totals = df.groupBy("customer_id").sum("quantity")
That’s a way more sophisticated operation and might have information to maneuver between completely different components of the job. That motion is usually the place the price is.
So the intermediate stage behavior is straightforward:
Earlier than reaching for tuning settings, ask a less complicated query: is that this line of code forcing Spark to maneuver information round?
This doesn’t imply joins, aggregations, or information sorting are dangerous. Quite the opposite, they’re important, however they’re the kind of operations it’s best to deal with with just a little extra care.
How PySpark divides the work
A PySpark DataFrame isn’t handled as a single contiguous block of knowledge. PySpark splits it into partitions. You may consider the partitions as chunks of knowledge that PySpark can course of individually and in parallel. That is likely one of the principal causes PySpark is performant and helpful for processing large-scale information.
You may test what number of partitions a DataFrame has like this:
df.rdd.getNumPartitions()
As output, you’ll get a easy integer quantity again, for instance:
8
Which means PySpark has break up the info into eight chunks, and this issues as a result of partitioning impacts how a lot parallel work PySpark can do. When you have too few partitions, PySpark might not be capable to use all accessible CPU cores. When you have too many partitions, PySpark might spend an excessive amount of time managing numerous tiny duties and information units. Neither excessive is right.
If both of those conditions arises, PySpark provides two helpful capabilities to assist.
- You may change the variety of partitions with repartition()
df_more = df.repartition(16)
- And you may cut back the variety of partitions with coalesce():
df_fewer = df.coalesce(4)
repartition() can improve or lower the variety of partitions, nevertheless it causes PySpark to redistribute the info, which can normally have a efficiency hit.
coalesce() is used to scale back the variety of partitions and wishes much less information motion.
A sensible rule is:
Use repartition() when it’s essential unfold information out extra evenly. Use coalesce() while you merely need fewer output information.
For instance, say you’ve a dataset that naturally produces 40 small-ish Parquet information when it’s written out to disk. You may do that as a substitute:
df.coalesce(4).write.mode("overwrite").parquet("output/gross sales")
That tells PySpark to put in writing the end result with 4 partitions, which usually produces 4 output information.
You don’t have to obsess over actual partition counts at this stage. The vital level is that partitions management how PySpark divides its work. When you perceive these concepts, PySpark turns into a lot simpler to motive about. Jobs that decelerate cease feeling like random occasions and also you begin to see the probably causes: a shuffle right here, a large be part of there, too many tiny information someplace else.
When information has to transfer
Some PySpark operations can occur inside every partition. Others require information from completely different partitions to be introduced collectively.
That motion is named a shuffle.
A shuffle occurs when PySpark has to redistribute information throughout partitions. This is likely one of the first Spark concepts that basically modifications the way you write PySpark. Shuffles aren’t uncommon, and so they aren’t routinely improper, however they’re normally computationally costly and needs to be averted if potential.
Widespread PySpark operations which will trigger shuffles embody:
groupBy()
be part of()
distinct()
orderBy()
repartition()
Take this instance:
df.groupBy("customer_id").sum("quantity")
Spark can’t calculate the ultimate complete for every buyer until all rows for that buyer are aggregated. That usually means transferring information between partitions.
Sorting is one other widespread instance:
df.orderBy("transaction_date")
Sorting a complete dataset requires PySpark to match and organize information throughout your complete dataset, not simply inside every partition. That’s one other shuffle operation. The primary lesson is:
Shuffles are costly.
You will want them as a result of actual information processing work depends upon grouping, becoming a member of, and sorting. But when a PySpark job feels gradual, shuffles are one of many first issues to analyze.
A helpful behavior to scale back shuffles is to filter and choose information earlier than costly operations that may set off them.
As a substitute of this:
df_joined = df_sales.be part of(df_customers, "customer_id")
df_london = df_joined.filter(df_joined.metropolis == "London")
Want this, the place potential:
df_london_customers = df_customers.filter(df_customers.metropolis == "London")
df_joined = df_sales.be part of(df_london_customers, "customer_id")
Eradicating pointless information as early as potential in your pipelines means PySpark has much less information to maneuver (shuffle) in the course of the be part of.
Why joins deserve further care
Joins are the place a whole lot of PySpark jobs begin to hit efficiency points. Not as a result of joins are dangerous, however as a result of matching rows throughout datasets typically means transferring information.
A easy be part of seems to be like this:
df_joined = df_sales.be part of(df_customers, on="customer_id", how="left")
Spark wants matching buyer IDs from each the gross sales and clients DataFrames to satisfy in the identical place. Relying on the scale and format of the info, that may contain a shuffle. There are three sensible issues you are able to do to make joins much less painful.
First, strive to make sure that your be part of keys are of the identical information sort. If that’s not potential, be sure you solid information varieties appropriately. If one desk shops buyer IDs as integers and one other shops them as strings, PySpark might produce errors or sudden outcomes.
from pyspark.sql import capabilities as F
df_sales = df_sales.withColumn(
"customer_id",
F.col("customer_id").solid("string")
)
df_customers = df_customers.withColumn(
"customer_id",F.col("customer_id").solid("string")
)
Second, solely preserve the columns you want by lowering information earlier than becoming a member of.
df_customers_small = df_customers.choose("customer_id","metropolis","loyalty_level")
And filter early if potential:
df_sales_recent = df_sales.filter(F.col("12 months") == 2026)
Then be part of:
df_joined = df_sales_recent.be part of( df_customers_small,on="customer_id",how="left")
Third, contemplate broadcasting small lookup tables. If one DataFrame may be very small, PySpark can copy it to every employee slightly than shuffle each datasets.
from pyspark.sql.capabilities import broadcast
df_joined = df_sales.be part of(broadcast(df_customers_small),on="customer_id,"how="left" )
That is known as a broadcast be part of and it’s particularly helpful for lookup tables, resembling buyer particulars, nation codes, product classes, or change charges.
A easy rule to recollect is that this:
If one aspect of the be part of is sufficiently small to suit comfortably in reminiscence, a broadcast be part of could also be quicker.
Don’t pressure broadcast joins in every single place. They’re helpful when the smaller desk is genuinely small. Attempting to broadcast a big desk can create extra issues than it solves.
Taking a look at what PySpark is planning
As a result of PySpark makes use of lazy analysis, it doesn’t run every transformation as quickly because it reads it. As a substitute, it builds up a plan for the way the work needs to be finished.
You may check out that plan with:
df.clarify("formatted")
The primary time you view an execution plan, the output can really feel overwhelming. You might even see phrases resembling:
Scan
Filter
Venture
Change
SortMergeJoin
BroadcastHashJoin
HashAggregate
You don’t want to grasp each line. At this stage, simply begin recognising just a few helpful clues.
- Scan means PySpark is studying information.
- Filter means PySpark is making use of a filter.
- Venture normally means PySpark is choosing columns or creating derived columns.
- Change normally means a shuffle.
- SortMergeJoin means PySpark is becoming a member of information by sorting and matching keys.
- HashAggregate means PySpark is grouping rows and calculating mixture values, resembling counts, sums, averages, minimums, or maximums.
- BroadcastHashJoin means PySpark is utilizing a broadcast be part of.
The one I might take note of first is:
For those who see this, it means PySpark is shuffling information between partitions. Once more, that isn’t routinely dangerous. However it tells you the place a number of the prices could also be. For instance:
df_totals = df.groupBy("customer_id").sum("quantity")
df_totals.clarify("formatted")
You’ll probably see an Change within the plan as a result of PySpark must group matching buyer IDs collectively.
It’s also possible to use the PySpark UI to analyze points, which I talked about briefly in my earlier article on this sequence. When PySpark is working domestically, you may normally open the PySpark UI at this URL:
http://localhost:4040
It reveals you which ones jobs have run, how lengthy every stage took, and whether or not one a part of your job is taking for much longer than the remainder.
You don’t want to grasp each TAB within the output of the Spark UI at this level. Simply get used to opening it once in a while to test that your jobs are progressing as they need to.
Utilizing clarify() and the PySpark UI is likely one of the greatest habits you may develop. You don’t have to develop into a PySpark internals skilled. Simply begin on the lookout for apparent clues. After some time, you cease making an attempt to learn each line and begin on the lookout for the handful of clues that matter.
Knowledge Caching
Caching is tempting as a result of it looks like a magic “make this quicker” button. Typically it does. Different instances, it simply makes use of up reminiscence with out serving to a lot.
You may mark a DataFrame for caching like this:
df_clean.cache()
However keep in mind: PySpark is lazy. This line doesn’t instantly cache the info. It solely tells PySpark:
When this DataFrame is computed, preserve it in reminiscence if potential.
To really materialise the cache, you want an motion like:
df_clean.rely()
Now PySpark computes df_clean and shops it. Caching is mostly solely helpful while you reuse the identical DataFrame a number of instances in the identical session.
For instance:
df_clean.cache()
df_clean.rely()
df_by_city = df_clean.groupBy("metropolis").sum("quantity")
df_by_level = df_clean.groupBy("loyalty_level").sum("quantity")
df_sample = df_clean.filter(F.col("quantity") > 1000)
Right here, caching might assist as a result of the identical cleaned dataset is reused in a number of later operations, however caching isn’t helpful should you solely use the DataFrame as soon as.
For instance, this cache name is pointless:
df_clean.cache()
df_final = df_clean.groupBy("metropolis").sum("quantity")
If df_clean isn’t reused, caching simply provides overhead.
You may take away a DataFrame from cache with:
df_clean.unpersist()
A superb rule of thumb is:
Cache solely when a DataFrame is dear to create and reused greater than as soon as. Don’t cache all the pieces. PySpark reminiscence is efficacious.
Writing information that PySpark can learn effectively
In my earlier PySpark article, I launched Parquet as the popular file format for studying and writing information. Now we will go one step additional.
You may write Parquet information partitioned by a number of columns:
df.write.mode("overwrite")
.partitionBy("12 months", "month")
.parquet("output/gross sales")
Relying on the info within the DataFrame, this may create an output folder format much like this:
output/gross sales/
-> 12 months=2025/
-> month=1/
-> month=5/
-> month=12/
-> 12 months=2026/
-> month=1/
-> month=2/
The helpful half is that when studying information again in from a folder construction like this, Spark doesn’t at all times must learn the entire dataset.
For those who later learn solely the 2026 information:
df_2026 = spark.learn.parquet("output/gross sales").filter(F.col("12 months") == 2026)
Spark can typically keep away from studying the 2025 folder altogether. That is known as partition pruning, and it will probably make reads a lot quicker when your filters match the partition columns.
Good partition columns are normally fields you generally filter by, resembling:
12 months
month
week
area
nation
division
However watch out. Don’t partition by a column with too many distinctive values.
For instance, that is normally a foul concept:
.partitionBy("transaction_id")
If each transaction ID is exclusive, PySpark might create hundreds or thousands and thousands of tiny folders and information. That makes the dataset tougher to handle and slower to learn.
A superb rule to recollect is:
Partition by columns you typically filter on, however keep away from columns with too many distinct values. Date-based partitioning is usually a smart place to start out.
Letting PySpark do the work
In PySpark, a UDF stands for a user-defined operate. It helps you to write your individual Python operate and apply it to a PySpark column in a Dataframe.
That is genuinely helpful in some circumstances. However Python UDFs could be slower than PySpark’s built-in capabilities.
Right here is an easy built-in model instance the place we wish to convert some textual content to uppercase:
from pyspark.sql import capabilities as F
df = df.withColumn("customer_name_upper",F.higher("customer_name") )
Spark understands F.higher(), and it will probably optimise it as a part of the execution plan.
Now examine that with a Python UDF:
from pyspark.sql.capabilities import udf
from pyspark.sql.varieties import StringType
def make_upper(worth):
if worth is None:
return None
return worth.higher()
make_upper_udf = udf(make_upper, StringType())
df = df.withColumn("customer_name_upper", make_upper_udf("customer_name") )
This works nevertheless it’s extra verbose and tougher to grasp and, crucially, PySpark has much less potential to optimise it. Knowledge might have to maneuver between PySpark’s engine and Python, which provides overhead.
So the sensible intermediate stage recommendation is:
Select PySpark built-in capabilities first. Use UDFs sparingly and solely when there is no such thing as a built-in operate that does what you want.
Widespread traps as your information grows
When you begin working with bigger datasets, some innocent-looking PySpark code may cause issues. One widespread entice is utilizing accumulate() on giant information.
rows = df.accumulate()
This brings all information from PySpark into Python in your driver machine.
For small information, that’s nice. For big datasets, this could overwhelm the driving force as a result of all rows are loaded into native Python reminiscence.
As a substitute, use:
df.present()
To see what your information seems to be like earlier than deciding what to do with it. Viewing the info set may aid you formulate a plan to filter it extra exactly or course of it extra optimally.
One other entice is looking rely() too typically.
df.rely()
Like present(), rely() is an motion. It causes PySpark to scan your complete dataset. It may be helpful, however don’t use it in every single place with out considering.
Sorting enormous datasets may also be costly.
df.orderBy("quantity")
International sorting typically requires a shuffle. For those who solely want a fast take a look at the biggest few rows, you may use this as a substitute:
df.orderBy(F.col("quantity").desc()).restrict(10)
Spark can typically optimise this as a top-k question. It nonetheless must scan the info, however it could keep away from sorting and returning your complete dataset.
One other widespread downside is becoming a member of information earlier than filtering it. The place potential, filter every dataset first, so Spark has fewer rows to maneuver and examine in the course of the be part of.
Writing tons of or hundreds of small information may decelerate future reads. When the output is sufficiently small, utilizing coalesce() can cut back the variety of information Spark writes:
df.coalesce(8).write.mode("overwrite").parquet("output/outcomes")
And at last, keep away from, keep away from, keep away from utilizing Python loops the place a DataFrame operation would work higher. This sample can set off many separate PySpark jobs:
for metropolis in cities:
df.filter(F.col("metropolis") == metropolis).rely()
That is higher:
df.groupBy("metropolis").rely()
Spark is happiest while you describe the end result you need and let it plan the route.
Placing the items collectively
Let’s pull the concepts we’ve mentioned collectively right into a easy workflow. Think about we’ve gross sales information saved as partitioned Parquet, plus a small buyer lookup desk.
We wish to:
- learn latest gross sales
- preserve solely helpful columns
- clear be part of keys
- be part of to buyer information
- calculate totals
- write partitioned output
- examine the plan
Here’s what that may seem like in PySpark code:
from pyspark.sql import capabilities as F
from pyspark.sql.capabilities import broadcast
gross sales = spark.learn.parquet("information/gross sales")
sales_recent = (
gross sales
.filter(F.col("12 months") == 2026)
.choose(
"transaction_id",
"customer_id",
"quantity",
"12 months",
"month",
)
.dropna(subset=["customer_id", "amount"])
.withColumn("customer_id", F.col("customer_id").solid("string"))
)
clients = (
spark.learn.parquet("information/clients")
.choose("customer_id", "metropolis", "loyalty_level")
.withColumn("customer_id", F.col("customer_id").solid("string"))
)
enriched = sales_recent.be part of(
broadcast(clients),
on="customer_id",
how="left",
)
monthly_totals = (
enriched
.groupBy("12 months", "month", "metropolis")
.agg(
F.sum("quantity").alias("total_amount"),
F.rely("*").alias("transaction_count"),
)
)
monthly_totals.clarify("formatted")
(
monthly_totals.write
.mode("overwrite")
.partitionBy("12 months", "month")
.parquet("output/monthly_totals")
)
There’s nothing flashy right here. However this can be a rather more mature PySpark workflow than merely loading a CSV and working a be part of.
What does the execution plan look like?
Since this workflow makes use of a broadcast be part of and an aggregation, it’s additionally second to take a look at the plan Spark may create. The precise plan depends upon your PySpark model, information dimension, file format, and whether or not PySpark decides to honour the printed trace. However for this instance, the simplified model of the plan may look one thing like this:
== Bodily Plan ==
AdaptiveSparkPlan
+- WriteFiles
+- Kind
+- HashAggregate
+- Change
+- HashAggregate
+- Venture
+- BroadcastHashJoin LeftOuter
:- Venture
: +- Filter
: +- Scan parquet information/gross sales
+- BroadcastExchange
+- Venture
+- Scan parquet information/clients
The important thing issues to note are:
BroadcastHashJoin - This tells you PySpark is utilizing the printed be part of, which is often factor.
Change – This tells you PySpark is doing a shuffle. On this workflow, the shuffle is anticipated due to:
.groupBy("12 months", "month", "metropolis")
Filter – And in case your gross sales information is partitioned by 12 months, you may additionally see PySpark pruning information while you filter:
.filter(F.col("12 months") == 2026)
Which means PySpark can skip irrelevant folders slightly than studying your complete dataset.
Abstract
Shifting to an intermediate stage in PySpark is much less about memorising settings and extra about understanding the concepts that specify how Spark behaves:
- Knowledge is split into partitions.
- Some operations run independently, whereas others require shuffles.
- Shuffles and joins could be costly.
- Caching solely helps when information is reused.
- Parquet file format impacts learn and write efficiency.
- Constructed-in capabilities are normally quicker than Python UDFs.
- Execution plans present how Spark intends to course of your information.
When you perceive these concepts, gradual jobs develop into simpler to diagnose. That’s the true step up.
Your first PySpark milestone was studying how you can run code. Subsequent, was studying how you can construct clear workflows. This third milestone is studying how you can make these workflows scale.
You don’t have to develop into a PySpark efficiency engineer in a single day. However should you can recognise partitions, shuffles, joins, caching, file format, and execution plans, you might be already writing PySpark with a way more skilled mindset.
For reference, listed here are the hyperlinks to the primary two articles on this sequence.















