Modern E-commerce systems generate massive volumes of data, orders, customers, payments, shipments, returns, browsing analytics. A production data lake must process this data efficiently and reliably on Databricks.
This guide explains every important PySpark optimization technique, with clear reasoning, when to use, when NOT to use, and Databricks-specific best practices.
We implement a classic Medallion Architecture:
Choose executors with 4–8 cores to avoid excessive JVM garbage collection.
Autoscaling lets the cluster expand during peak loads and shrink when idle, reducing cost.
Balanced memory and core allocation ensures consistent performance and avoids skewed executor workloads.
What matters:
Guideline:
Why?
If your executor has 32 cores, Spark will launch 32 tasks on the same JVM → GC thrashing → slows down your job.
Delta Lake provides ACID transactions, schema evolution, time travel, and fast metadata handling. It is optimized for modern E-commerce workloads that require upserts and incremental loads.
Delta avoids the overhead of raw Parquet/CSV and enables features like OPTIMIZE and ZORDER.
For production E-commerce workloads:
Format | When to Use | Why |
Delta | Always | ACID, upserts, auto-optimize, vacuum |
Parquet | rarely | No transaction support |
CSV/JSON | NEVER | Slow, heavy parsing |
Filtering early reduces the number of files Spark needs to scan. Predicates push filters down to the storage layer, pruning unnecessary data. This minimizes I/O and speeds up joins, aggregates, and downstream tasks.
Filtering EARLY reduces the number of rows scanned.
%pyspark
df = spark.read.format("delta").load("/mnt/bronze/orders") \
.filter("order_date >= '2025-01-01'")
Selecting only required columns reduces data read from disk and serialized in memory.
This cuts down shuffle size, memory usage, and network traffic.
It significantly speeds up wide tables with hundreds of columns.
Only select required columns.
Bad:
%pyspark
df = spark.read.load(path)
Good:
%pyspark
df = spark.read.load(path).select("order_id", "customer_id", "amount")
WHY?
Fewer columns → fewer bytes read → faster serialization/deserialization.
These operations pull data to the driver, causing OOM or crashes. Use only for debugging tiny samples; otherwise rely on display(), limit(), or Delta writes. Avoiding collect prevents bottlenecks on the driver node.
Use them only for debugging.
Instead, use:
%pyspark
display(df.limit(20))
OR write to a temp view:
%pyspark
df.createOrReplaceTempView("tmp")
95% of performance issues come from JOINS. We cover all techniques:
Spark sends the small table to all workers, avoiding shuffles. This turns a huge join into a fast map-side join. Use when a table is small enough to fit in executor memory. Spark's default broadcast threshold is 10MB. You can raise it, but broadcasting hundreds of MB risks OOM, so 500MB is not a safe universal cutoff. With AQE, Spark also auto-broadcasts at runtime once it sees the real table size.
Example: Customers (small) ⟶ Orders (huge)
%pyspark
from pyspark.sql.functions import broadcast
df = orders.join(broadcast(customers), "customer_id", "left")
Why it works:
Skew happens when a few keys hold massive data (e.g., one customer with thousands of orders). It slows down a single partition while others finish quickly. Techniques like salting or skewJoinHint() distribute data more evenly across partitions. E-commerce has skew keys like "customer_id = 99999" with 1M orders.
Symptoms:
Solution A (first choice): let AQE handle it. On by default, it auto-splits any partition bigger than 5x the median: spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
Solution B (fallback): salt BOTH sides, or the join drops rows. Add a random salt to the big table's key, and REPLICATE the small table across all salt values:
from pyspark.sql.functions import rand, explode, array, lit
orders_salted = orders.withColumn("salt", (rand() * 10).cast("int"))
salts = array([lit(i) for i in range(10)])
customers_salted = customers.withColumn("salt", explode(salts))
df = orders_salted.join(customers_salted, ["customer_id", "salt"])
Bucketed tables hash-partition data by a specific key for faster repeated joins. This reduces shuffle during joins since matching buckets get colocated. Great for large tables like Orders–Payments joined frequently.
For huge repeated joins like Orders ↔ Payments.
Create bucketed tables:
%pyspark
df.write \
.bucketBy(8, "customer_id") \
.sortBy("customer_id") \
.saveAsTable("silver.orders_bucketed")
Effect:
Best for joining two very large tables with sortable keys.
Spark sorts both tables and merges them efficiently during the join.
More stable than shuffle-hash join for large analytical datasets.
Use only for BIG ↔ BIG joins.
This is already the default, so no need to set it. With AQE, Spark auto-switches between sort-merge and broadcast joins at runtime based on real data size. Just keep join keys clean and sortable.
Section 4: File Optimizations
OPTIMIZE compacts many small files into large, efficient ones. ZORDER boosts query speed by colocating related data (e.g., customer_id + order_date). Greatly improves lookup and range queries in E-commerce workloads.
For query acceleration on high-volume E-commerce tables:
OPTIMIZE (compacts small files) is still essential. But for data layout, Databricks now recommends LIQUID CLUSTERING over ZORDER for all new tables (GA since 2024, DBR 15.4 LTS+):
CREATE TABLE orders (...) CLUSTER BY (customer_id, order_date);
OPTIMIZE orders; -- clusters incrementally
Unlike ZORDER, you can change clustering keys later without rewriting. Keep ZORDER only on old tables that already use it (can't mix the two).
Impact:
Databricks automatically merges small files created by streaming or small batch writes. This reduces file fragmentation and improves read performance. Auto-optimize keeps Delta tables healthy without manual maintenance.
Enable on Databricks:
SET spark.databricks.delta.autoCompact.enabled = true;
SET spark.databricks.delta.optimizeWrite.enabled = true;
Prevents:
Cache when a DataFrame is reused multiple times in a job. It avoids recomputing expensive transformations and speeds up iterative workloads. Use cache() for general cases and persist() for custom memory levels.
Use when:
df.cache()
df.count()
Don't cache:
Different storage levels balance memory and disk usage. MEMORY_ONLY is fastest but risky; MEMORY_AND_DISK is the safe default. Choose based on table width and memory availability.
Examples:
Operations like groupBy, orderBy, and distinct trigger large shuffles. These moves data across the network and slow down pipelines. Use window functions or approximate functions to reduce shuffle impact.
Expensive:
Use:
Spark’s default partitions (200) may be too low or too high. Tune partitions based on cluster CPU cores to balance work evenly. Proper partitioning prevents slow tasks and avoids tiny or oversized partitions.
Default = 200
Better = based on cluster size
Manually tuning this is now obsolete. AQE (on by default since Spark 3.2 / DBR 12.2+) coalesces shuffle partitions at runtime. Tune by target partition SIZE (~128MB), not core count:
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "134217728") # ~128MB
MERGEs become slow when join conditions are complex. Pre-clean keys in Bronze so Silver MERGEs use simple equality conditions. This reduces file scans and accelerates both updates and inserts.
Avoid complex conditions:
Bad:
%sql
merge into silver.orders t
using bronze.orders s
on trim(lower(t.order_id)) = trim(lower(s.order_id))
Good:
CDF captures only changed rows instead of scanning entire tables. Perfect for incremental processing and faster MERGE jobs. Ideal for E-commerce data like orders updated multiple times.
If performing repeated MERGEs:
Enable Change Data Feed (CDF):
%sql
ALTER TABLE bronze.orders SET TBLPROPERTIES (delta.enableChangeDataFeed = true);
Uses:
Vacuum cleans old Delta files and frees storage. It maintains healthy table versions and improves metadata performance. Set retention to 168 hours (7 days) for production safety.
%sql
VACUUM orders RETAIN 168 HOURS;
Prevents:
Process only new or changed data instead of full refresh. Reduces compute cost and speeds up all pipelines dramatically. Bronze → Silver → Gold flows become efficient and stable. Do not reread full data everyday.
Pattern:
Write reusable functions for cleansing, validation, and transformations. Makes your code maintainable, testable, and scalable across pipelines. Improves readability and reduces duplication.
Write reusable transformers:
%pyspark
def clean_orders(df):
return df \
.withColumn("amount", col("amount").cast("double")) \
.dropDuplicates(["order_id"])
Makes pipeline maintainable & testable.
Long lineage chains slow Spark and can cause optimizer failures. Checkpoint breaks lineage by writing the DataFrame to disk.Useful in complex Silver transformations or multi-join pipelines. Avoid plan explosions.
df = df.checkpoint()
Resets lineage. Prevents stack overflows.
Calling actions like count() repeatedly causes Spark to re-execute full jobs. Push filtering operations outside loops and use vectorized operations. Better performance and fewer job triggers.
Bad:
for id in id_list:
df.filter(...).count()
Better:
UDFs break Spark’s optimizer and are slower than native functions.
Use built-in PySpark functions for best performance.
If necessary, use Pandas UDFs which are vectorized and faster.
UDF = slow
Pandas UDF = faster
Native PySpark = fastest
Setting trigger intervals controls how frequently new data is processed. Too fast triggers overload the cluster; too slow delays processing. Choose intervals based on ingestion rate.
trigger(processingTime="1 minute")
Watermarks bound how long Spark waits for late-arriving data. Prevents unbounded state growth in streaming aggregations. Improves stability and reduces memory pressure.
df.withWatermark("event_time", "2 hours")
Delta manages checkpoints, small files, and ACID transactions. Combining streaming + Delta + auto-optimization ensures reliability. Perfect for E-commerce events like clicks, carts, payments. Delta manages checkpointing, compaction.
Optimizing Spark for large-scale E-commerce isn't about using every technique at once, but applying the right one at the right layer. Nearly every slowdown traces back to three causes: reading too much data, shuffling too much, or letting Delta tables decay into small files. Filter and prune early, choose join strategies wisely since joins cause most performance issues, keep tables healthy with OPTIMIZE and VACUUM, and process incrementally. Measure continuously with the Spark UI, and performance becomes a property of your design.