Back to Advanced Data Engineering
Q24Question 24 of 24

Why withColumn in a loop is an anti-pattern

Key Concepts
  • Each withColumn call returns a new DataFrame whose plan wraps the previous one in another Project node.
  • Spark re-analyzes the growing plan on the Driver each time, making total analysis cost O(n-squared) for n columns.
  • The anti-pattern bites when looping over many columns; a handful of withColumn calls is fine and readable.
  • Fix: accumulate all column expressions and apply them in a single select() producing one Project node.
  • Spark 3.3+ offers withColumns() which takes a dict of name-to-expression and adds them all at once.
  • This is purely a plan-construction optimization; it does not change the result, only how fast the plan is built.

Definition

withColumn in a loop re-analyzes a deepening plan each iteration (O(n-squared) on the Driver); add all columns at once with a single select or withColumns.
  • The problem – each withColumn call returns a new DataFrame with a new Project node wrapping the previous plan, and Spark re-analyzes the growing plan each time.
  • The cost – for n columns, analysis grows roughly O(n-squared), making plan construction extremely slow before any data is processed.
  • The fix – accumulate all expressions and apply them in a single select() or use withColumns() (Spark 3.3+), producing one Project node and one analysis pass.
  • Scope – a handful of withColumn calls is fine; the anti-pattern bites specifically when looping over dozens or hundreds of columns.

Imagine adding one new column to the National Bank's report by rewriting the entire report from scratch each time, then doing that again for the next column, a hundred times. Each rewrite re-reads and re-checks everything that came before, so the hundredth addition re-processes ninety-nine layers. Instead, list all hundred columns once and build the report in a single pass.

Understanding the Concepts

Why does withColumn in a loop cause problems?

Spark DataFrames are immutable and lazy. Each transformation returns a new DataFrame with a new logical plan referencing the previous one. withColumn adds one column and wraps the prior plan in a Project node.

PYTHON
from pyspark.sql import SparkSession, functions as F
import time

spark = SparkSession.builder.appName("WithColumnLoop").getOrCreate()

txn = spark.read.parquet("/lake/silver/transactions")

# Anti-pattern: loop adds 50 Project nodes
start = time.time()
df = txn
for i in range(50):
    df = df.withColumn(f"col_{i}", F.col("amount") * (i + 1))
print(f"Plan construction time: {time.time() - start:.2f}s")
TEXT
Plan construction time: 12.45s  # minutes for hundreds of columns

No data has been processed yet. All the time is spent on the Driver building and analyzing the plan.

What does the plan look like?

Each withColumn adds a nested Project. After 50 calls, the plan is 50 layers deep.

PYTHON
df.explain(True)
TEXT
== Analyzed Logical Plan ==
Project [*, (amount * 50) AS col_49]
+- Project [*, (amount * 49) AS col_48]
   +- Project [*, (amount * 48) AS col_47]
      +- ... 47 more Project layers ...
         +- Relation[txn_id, account_id, amount, ...] parquet

The optimizer eventually collapses these into one Project, but it still had to build and analyze the deep plan first.

What is the fix using select?

Accumulate all expressions in a list and apply them in a single select.

PYTHON
# Fix: one select, one Project, one analysis pass
start = time.time()

expressions = [F.col("*")]  # keep all existing columns
for i in range(50):
    expressions.append((F.col("amount") * (i + 1)).alias(f"col_{i}"))

df = txn.select(*expressions)
print(f"Plan construction time: {time.time() - start:.2f}s")
TEXT
Plan construction time: 0.03s  # 400x faster

The plan is flat: one Project node with all columns.

What is the fix using withColumns (Spark 3.3+)?

Spark 3.3 introduced withColumns which takes a dict of name-to-expression.

PYTHON
# Spark 3.3+ fix
column_map = {
    f"col_{i}": F.col("amount") * (i + 1)
    for i in range(50)
}

df = txn.withColumns(column_map)

This produces a single Project node internally, equivalent to the select approach.

What is a real-world example?

A common real-world case is dynamically casting many columns from string to their proper types during ingestion.

PYTHON
# Anti-pattern: loop over schema to cast columns
raw = spark.read.csv("/landing/trades/", header=True, inferSchema=False)

type_map = {
    "trade_id": "long",
    "account_id": "long",
    "amount": "decimal(18,2)",
    "trade_date": "date",
    "branch_code": "string",
    # ... 45 more columns
}

# Bad: O(n^2) plan construction
df = raw
for col_name, col_type in type_map.items():
    df = df.withColumn(col_name, F.col(col_name).cast(col_type))

# Good: single select
expressions = [
    F.col(name).cast(dtype).alias(name)
    for name, dtype in type_map.items()
]
df = raw.select(*expressions)

When is withColumn acceptable?

A handful of withColumn calls is perfectly fine and often more readable than a big select.

PYTHON
# This is fine – only 3 calls, clear and readable
df = txn \
    .withColumn("fee", F.col("amount") * 0.001) \
    .withColumn("net_amount", F.col("amount") - F.col("fee")) \
    .withColumn("processed_at", F.current_timestamp())

The anti-pattern bites only when the number of calls is large, typically dozens or more.

How do you identify this problem in production?

Long plan construction time shows up as slow "planning" in the Spark UI before any tasks run.

PYTHON
# Check the plan depth
plan_str = df._jdf.queryExecution().analyzed().toString()
project_count = plan_str.count("Project")
print(f"Nested Project nodes: {project_count}")
# If this is > 20, consider switching to select

What should a senior engineer know?

  • This is a plan-construction optimization, not a data-processing change. The results are identical.
  • The cost is quadratic on the Driver because each analysis pass walks the full accumulated plan.
  • For very deep plans, Spark can also hit stack overflow errors during analysis.
  • withColumns (Spark 3.3+) is the cleanest fix; select with a list is the universal fix for all versions.
  • Always accumulate expressions in a list or dict when deriving many columns dynamically.
PYTHON
# Senior pattern: accumulate, then apply
def add_derived_columns(df, column_specs):
    """Add many columns in a single select to avoid O(n^2) planning."""
    expressions = [F.col("*")]
    for name, expr in column_specs.items():
        expressions.append(expr.alias(name))
    return df.select(*expressions)

What are common interview follow-up questions?

  • Does this affect execution time? No, only plan construction on the Driver. The optimizer collapses the Projects before execution.
  • Can the optimizer fix this automatically? It collapses Projects after they are built, but cannot avoid the quadratic analysis during construction.
  • How many withColumn calls are "too many"? There is no hard cutoff. 5-10 is fine; 50+ is where it becomes noticeably slow.

Visual Overview

DIAGRAM
  withColumn Loop vs Single Select
  ─────────────────────────────────

  Loop (50 calls):                    Single select:
  ┌──────────────┐                    ┌──────────────────────┐
  │ Project 50   │                    │ Project (all 50 cols)│
  │ Project 49   │                    │   col_0 = amount*1   │
  │ Project 48   │                    │   col_1 = amount*2   │
  │ ...          │                    │   ...                │
  │ Project 1    │                    │   col_49 = amount*50 │
  │ FileScan     │                    │ FileScan             │
  └──────────────┘                    └──────────────────────┘
  50 analysis passes                  1 analysis pass
  O(n^2) total                        O(n) total
  12+ seconds                         0.03 seconds

Key Takeaway

Calling withColumn inside a loop is an anti-pattern because each call returns a new DataFrame whose plan wraps the previous one in another Project node, and Spark re-analyzes the entire growing plan on the Driver each time. For n columns, the total analysis cost grows roughly O(n-squared), so with many columns the job can spend minutes just constructing the plan before reading a single row. The optimizer does eventually collapse the stacked Projects, but it still had to build and analyze the deep plan first.

The fix is to add all columns in a single step. Accumulate every expression and apply them with one select that produces a single Project node and one analysis pass. On Spark 3.3+, use withColumns which takes a dict and adds everything in one call. Both build the plan once instead of n times, and the results are identical.

At the National Bank of India, a wide ingestion job that cast fifty string columns to proper types with a withColumn loop spent minutes building the plan before any data moved. Collecting the fifty cast expressions into a list and applying them in a single select dropped plan construction to a fraction of a second. The nuance is that a handful of withColumn calls is perfectly fine and readable; the anti-pattern bites specifically when looping over many columns.

Remember one sentence: never loop withColumn over many columns because each call re-analyzes the full plan at O(n-squared) cost; accumulate all expressions and apply them in one select or withColumns call.

---

Share
Use ← → arrow keys to navigate