Skip to main content
PySpark beginner Lesson 3 of 10

Transforming Columns in PySpark

Add, rename, and reshape columns with withColumn, when/otherwise, and the functions module — and see why chaining withColumn in a loop is a trap.

Most PySpark work is reshaping columns. The API is small — a handful of methods plus the functions module — but a few of its behaviours surprise people coming from Pandas.

The data

from pyspark.sql import SparkSession, functions as F

spark = SparkSession.builder.appName("transform").master("local[*]").getOrCreate()

df = spark.createDataFrame(
    [
        (1, "alice",  "engineering", 95000, "2019-03-15", None),
        (2, "bob",    "engineering", 87000, "2021-07-01", "alice"),
        (3, "carol",  "design",      78000, "2020-01-20", None),
        (4, "dave",   "design",      82000, "2018-11-05", "carol"),
        (5, "erin",   "sales",       67000, "2022-05-30", None),
    ],
    ["id", "name", "team", "salary", "hired", "manager"],
)
df.show()
+---+-----+-----------+------+----------+-------+
| id| name|       team|salary|     hired|manager|
+---+-----+-----------+------+----------+-------+
|  1|alice|engineering| 95000|2019-03-15|   NULL|
|  2|  bob|engineering| 87000|2021-07-01|  alice|
|  3|carol|     design| 78000|2020-01-20|   NULL|
|  4| dave|     design| 82000|2018-11-05|  carol|
|  5| erin|      sales| 67000|2022-05-30|   NULL|
+---+-----+-----------+------+----------+-------+

Adding a column

df.withColumn("monthly", F.round(F.col("salary") / 12, 2)).show()
+---+-----+-----------+------+----------+-------+-------+
| id| name|       team|salary|     hired|manager|monthly|
+---+-----+-----------+------+----------+-------+-------+
|  1|alice|engineering| 95000|2019-03-15|   NULL|7916.67|
|  2|  bob|engineering| 87000|2021-07-01|  alice| 7250.0|
|  3|carol|     design| 78000|2020-01-20|   NULL| 6500.0|
|  4| dave|     design| 82000|2018-11-05|  carol|6833.33|
|  5| erin|      sales| 67000|2022-05-30|   NULL|5583.33|
+---+-----+-----------+------+----------+-------+-------+

withColumn returns a new DataFrame — df is unchanged. Nothing in Spark mutates in place.

Using the same name replaces the column instead of adding one:

df.withColumn("salary", F.col("salary") * 1.1).select("name", "salary").show()
+-----+------------------+
| name|            salary|
+-----+------------------+
|alice|104500.00000000001|
|  bob| 95700.00000000001|
|carol| 85800.00000000001|
| dave|90200.00000000001|
| erin| 73700.00000000001|
+-----+------------------+

Those trailing digits are floating-point again — 1.1 is not exactly representable. Cast to a decimal when the result is money:

df.withColumn("salary", (F.col("salary") * 1.1).cast("decimal(10,2)")) \
  .select("name", "salary").show()
+-----+---------+
| name|   salary|
+-----+---------+
|alice|104500.00|
|  bob| 95700.00|
|carol| 85800.00|
| dave| 90200.00|
| erin| 73700.00|
+-----+---------+

Conditionals

when / otherwise is Spark’s CASE WHEN:

banded = df.withColumn(
    "band",
    F.when(F.col("salary") >= 90000, "senior")
     .when(F.col("salary") >= 78000, "mid")
     .otherwise("junior"),
)
banded.select("name", "salary", "band").show()
+-----+------+------+
| name|salary|  band|
+-----+------+------+
|alice| 95000|senior|
|  bob| 87000|   mid|
|carol| 78000|   mid|
| dave| 82000|   mid|
| erin| 67000|junior|
+-----+------+------+

Conditions are evaluated in order, first match wins. Omitting otherwise gives null, not an error:

df.withColumn("band", F.when(F.col("salary") >= 90000, "senior")) \
  .select("name", "band").show()
+-----+------+
| name|  band|
+-----+------+
|alice|senior|
|  bob|  NULL|
|carol|  NULL|
| dave|  NULL|
| erin|  NULL|
+-----+------+

Four silent nulls. Always write otherwise, even if only to make the fallback explicit.

Null handling

Comparing to null never matches:

print("manager == null:", df.filter(F.col("manager") == None).count())
print("manager.isNull():", df.filter(F.col("manager").isNull()).count())
manager == null: 0
manager.isNull(): 3

Three rows have a null manager, but == None found none of them. In SQL semantics null means unknown, so unknown = unknown is unknown, and a filter keeps only rows that are true.

Fill or drop them explicitly:

df.fillna({"manager": "(none)"}).select("name", "manager").show()
+-----+-------+
| name|manager|
+-----+-------+
|alice| (none)|
|  bob|  alice|
|carol| (none)|
| dave|  carol|
| erin| (none)|
+-----+-------+

coalesce picks the first non-null of several columns, which is the usual way to apply a fallback:

df.withColumn("reports_to", F.coalesce(F.col("manager"), F.lit("unassigned"))) \
  .select("name", "reports_to").show(3)
+-----+----------+
| name|reports_to|
+-----+----------+
|alice|unassigned|
|  bob|     alice|
|carol|unassigned|
+-----+----------+

Strings and dates

df.select(
    F.initcap("name").alias("name"),
    F.upper("team").alias("team"),
    F.concat_ws(" @ ", F.col("name"), F.col("team")).alias("label"),
    F.length("name").alias("len"),
).show()
+-----+-----------+-------------------+---+
| name|       team|              label|len|
+-----+-----------+-------------------+---+
|Alice|ENGINEERING|alice @ engineering|  5|
|  Bob|ENGINEERING|    bob @ engineering|  3|
|Carol|     DESIGN|      carol @ design|  5|
| Dave|     DESIGN|       dave @ design|  4|
| Erin|      SALES|        erin @ sales|  4|
+-----+-----------+-------------------+---+

Dates need a cast first, since hired came in as a string:

dated = df.withColumn("hired", F.to_date("hired"))
dated.select(
    "name",
    "hired",
    F.year("hired").alias("year"),
    F.date_format("hired", "MMM yyyy").alias("month"),
    F.floor(F.months_between(F.lit("2026-01-01"), F.col("hired")) / 12).alias("tenure_yrs"),
).show()
+-----+----------+----+--------+----------+
| name|     hired|year|   month|tenure_yrs|
+-----+----------+----+--------+----------+
|alice|2019-03-15|2019|Mar 2019|         6|
|  bob|2021-07-01|2021|Jul 2021|         4|
|carol|2020-01-20|2020|Jan 2020|         5|
| dave|2018-11-05|2018|Nov 2018|         7|
| erin|2022-05-30|2022|May 2022|         3|
+-----+----------+----+--------+----------+

The withColumn loop trap

This looks harmless and is not:

import time

wide = df
start = time.perf_counter()
for i in range(100):
    wide = wide.withColumn(f"c{i}", F.col("salary") + i)
print(f"built plan in {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
wide.count()
print(f"count took {time.perf_counter() - start:.2f}s")
built plan in 4.31s
count took 18.74s

Each withColumn nests another projection, producing a plan 100 levels deep that Catalyst must walk repeatedly. Build one projection instead:

start = time.perf_counter()
wide = df.select("*", *[(F.col("salary") + i).alias(f"c{i}") for i in range(100)])
print(f"built plan in {time.perf_counter() - start:.2f}s")

start = time.perf_counter()
wide.count()
print(f"count took {time.perf_counter() - start:.2f}s")
built plan in 0.09s
count took 0.61s

Thirty times faster, same result. Whenever you find yourself calling withColumn in a loop, collect the expressions and apply them in one select — or use withColumns with a dict, which does the same thing in Spark 3.3+:

wide = df.withColumns({f"c{i}": F.col("salary") + i for i in range(100)})

Renaming and dropping

df.withColumnRenamed("hired", "start_date") \
  .drop("manager", "id") \
  .show(2)
+-----+-----------+------+----------+
| name|       team|salary|start_date|
+-----+-----------+------+----------+
|alice|engineering| 95000|2019-03-15|
|  bob|engineering| 87000|2021-07-01|
+-----+-----------+------+----------+

drop ignores names that do not exist rather than raising — convenient, and occasionally the reason a column you meant to remove is still there.

Practice

1. Add a bonus column: 15% for engineering, 10% for design, 5% otherwise.
df.withColumn("bonus",
    F.when(F.col("team") == "engineering", F.col("salary") * 0.15)
     .when(F.col("team") == "design",      F.col("salary") * 0.10)
     .otherwise(F.col("salary") * 0.05).cast("decimal(10,2)")
).select("name", "team", "bonus").show()
+-----+-----------+--------+
| name|       team|   bonus|
+-----+-----------+--------+
|alice|engineering|14250.00|
|  bob|engineering|13050.00|
|carol|     design| 7800.00|
| dave|     design| 8200.00|
| erin|      sales| 3350.00|
+-----+-----------+--------+
2. Count employees whose manager is null, using a SQL string filter.
print(df.filter("manager IS NULL").count())
3

filter accepts SQL strings as well as column expressions, and SQL’s IS NULL behaves correctly where = NULL does not. Useful when porting existing SQL.

3. What does F.col('a') == F.col('b') return when both are null?
spark.createDataFrame([(None, None)], "a string, b string") \
     .select((F.col("a") == F.col("b")).alias("eq"),
             F.col("a").eqNullSafe(F.col("b")).alias("eq_safe")).show()
+----+-------+
|  eq|eq_safe|
+----+-------+
|NULL|   true|
+----+-------+

== gives null; eqNullSafe treats two nulls as equal. Use eqNullSafe in join conditions where a null key should match.

4. Chain 50 withColumn calls and compare explain() output length to the single-select version.

The looped version emits a plan with 50 nested Project nodes; the single select emits one. The runtime result is identical, but analysis time grows roughly quadratically with nesting depth — which is why a job can spend minutes “doing nothing” before its first task starts.

Next: grouping and aggregation, and what a shuffle costs.

Frequently Asked Questions

Why use F.col('x') instead of df.x?
F.col refers to a column by name without binding to a specific DataFrame, so it keeps working after a join where two DataFrames share a column name, and it lets you build expressions before the DataFrame exists. df.x is fine for quick work but breaks in exactly the situations that matter.
Is withColumn slow if I call it many times?
Yes, in the planner rather than at runtime. Each call adds a projection to the logical plan, and a loop of 100 calls builds a deeply nested plan that Catalyst can take minutes to analyse. Use a single select with a list of expressions, or withColumns with a dict, instead.
What is the difference between filter and where?
Nothing. where is an alias for filter, provided so SQL users feel at home. Both accept a column expression or a SQL string, and both produce the same plan.
Why does my comparison to null return no rows?
In SQL semantics null is unknown, so null = null is null rather than true, and a filter keeps only rows where the condition is true. Use isNull(), isNotNull(), or eqNullSafe() to compare against null.