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.
Architecture Overview
We implement a classic Medallion Architecture:
- Bronze: Raw E-commerce data (orders, customers, payments)
- Silver: Cleaned, conformed, standardized layers
- Gold: Aggregations (GMV, AOV, RFM clustering, funnels)
Data arrives through:
- Incremental ingestion from APIs & CDC
- Batch ingestion from S3/ADLS
- Streaming events (optional)
Section 1: PySpark & Databricks Performance Foundations
1. Cluster Sizing & Autoscaling
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:
- Number of executors
- Executor memory
- Number of cores
- Workers vs driver roles
Guideline:
- Avoid too many cores per executor→ more GC overhead
- Optimal: 4–8 cores per executor
- Use autoscaling: min 2 → max 8 workers
Why?
If your executor has 32 cores, Spark will launch 32 tasks on the same JVM → GC thrashing → slows down your job.
2. Storage Formats: Delta Lake is mandatory
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 |
Section 2 : Spark DataFrame Optimizations
3. Predicate Pushdown
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'")
- Spark prunes files
- Reduces I/O
- Improves cache and join operations
4. Column Pruning
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.
5. Avoid collect() and toPandas()
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")
Section 3: Join Optimization (Most Important for E-commerce)
95% of performance issues come from JOINS. We cover all techniques:
6. Broadcast Joins
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:
- The small table is copied to all executors
- No shuffle happens
- Much faster join (map-side, no shuffle)
7. Skew Join Optimization
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:
- One partition runs for 30 minutes
- Others finish in seconds
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"])
8. Bucketed Joins
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:
- Shuffle minimized
- Join becomes faster for ALL future operations
9. Sort-Merge Join Tuning
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
10. Optimize & Zorder (Databricks Only)
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:
- Prunes unnecessary files
- Makes point-lookup & range queries blazing fast
11. AUTO OPTIMIZE + AUTOCOMPACTION
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:
- Too many small files
- Write amplification
- Slow downstream jobs
Section 5: Caching & Reuse
12. Cache only when needed
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:
- Same DataFrame used multiple times
- Very expensive transformations
df.cache()
df.count()
Don't cache:
- Large unreused DataFrames
- Anything you write to Delta (Delta already caches metadata)
13. Use persist(storageLevel=...) for advanced caching
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:
- MEMORY_ONLY – Fastest, but can OOM
- MEMORY_AND_DISK – Safe default
- DISK_ONLY – Useful for wide tables
Section 6: Shuffle Optimization
14. Avoid Wide Transformations
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:
- groupBy
- orderBy
- distinct
- joins
Use:
- approx_count_distinct() instead of count(distinct)
- window functions instead of groupBy where possible
15. Increase Shuffle Partitions Smartly
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
Section 7: Delta Lake Optimizations
16. MERGE Condition Optimization
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:
- Clean columns in Bronze
- Standardize keys
- Use simple = equality
17. Deletions & Updates on Huge Delta Tables
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:
- Incremental processing
- Fast MERGE (no need to scan full table)
18. Vacuum (mandatory in production)
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:
- Storage bloat
- Old version accumulation
Section 8: Job Design Optimizations
19. Use Incremental Pipelines
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:
- Bronze: append
- Silver: MERGE incremental
- Gold: recompute selectively
20. Modular Code + Reusable Functions
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.
21. Use checkpoint in long pipelines
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.
Section 9: PySpark Actions vs Transformations
22. Avoid triggers inside loops
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:
- Perform one filter using isin(id_list)
- Or broadcast small list as DataFrame
23. Avoid UDFs unless needed
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
Section 10: Streaming Optimizations (Optional)
24. Use Trigger Intervals
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")
25. Use watermarking
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")
26. Write to Delta with Auto-Optimize
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.
Conclusion
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.