from pyspark.sql import functions as F
catalog = spark.sql("SELECT current_catalog()").first()[0]
schema = "dbx_course"
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {catalog}.{schema}")
spark.sql(f"CREATE VOLUME IF NOT EXISTS {catalog}.{schema}.raw")
spark.sql(f"USE SCHEMA {catalog}.{schema}") # unqualified table names now point here
base = f"/Volumes/{catalog}/{schema}/raw"
def pick(col, n, salt):
"""Repeatable pseudo-random integer in [0, n), derived from a column value."""
return F.pmod(F.hash(col, F.lit(salt)), F.lit(n))
# 200 customers
countries = F.array(*[F.lit(c) for c in ["NL", "DE", "FR", "BE", "ES"]])
customers = (
spark.range(1, 201)
.select(F.col("id").cast("int").alias("customer_id"))
.withColumn("name", F.concat(F.lit("Customer "), F.col("customer_id").cast("string")))
.withColumn("country", F.element_at(countries, pick(F.col("customer_id"), 5, 1) + 1))
.withColumn("signup_date",
F.date_add(F.lit("2024-01-01").cast("date"), pick(F.col("customer_id"), 365, 2)))
)
# 30 products
categories = F.array(*[F.lit(c) for c in ["Books", "Games", "Home", "Office"]])
products = (
spark.range(1, 31)
.select(F.col("id").cast("int").alias("product_id"))
.withColumn("product_name", F.concat(F.lit("Product "), F.col("product_id").cast("string")))
.withColumn("category", F.element_at(categories, pick(F.col("product_id"), 4, 3) + 1))
.withColumn("unit_price", (pick(F.col("product_id"), 95, 4) + F.lit(5.99)).cast("decimal(8,2)"))
)
# 2,000 orders, then made realistically messy
statuses = F.array(*[F.lit(s) for s in
["completed", "completed", "completed", "shipped", "cancelled", "returned"]])
orders = (
spark.range(1, 2001)
.select(F.col("id").cast("int").alias("order_id"))
.withColumn("customer_id", pick(F.col("order_id"), 200, 5) + 1)
.withColumn("product_id", pick(F.col("order_id"), 30, 6) + 1)
.withColumn("quantity", pick(F.col("order_id"), 5, 7) + 1)
.withColumn("status", F.element_at(statuses, pick(F.col("order_id"), 6, 8) + 1))
.withColumn("order_ts", F.expr(
"timestampadd(SECOND, pmod(hash(order_id, 9), 31536000), TIMESTAMP'2025-01-01 00:00:00')"))
# missing values: every 97th order has no customer, every 131st has no quantity
.withColumn("customer_id",
F.when(F.col("order_id") % 97 == 0, F.lit(None).cast("int")).otherwise(F.col("customer_id")))
.withColumn("quantity",
F.when(F.col("order_id") % 131 == 0, F.lit(None).cast("int")).otherwise(F.col("quantity")))
)
# duplicate rows: every 50th order appears twice
orders = orders.unionByName(orders.filter(F.col("order_id") % 50 == 0))
# write three different file formats
(customers.coalesce(1).write.mode("overwrite")
.option("header", True).csv(f"{base}/customers_csv"))
products.coalesce(1).write.mode("overwrite").json(f"{base}/products_json")
orders.write.mode("overwrite").parquet(f"{base}/orders_parquet")
Module 4: Working with Data in Databricks
Read, shape, join and write data with PySpark and SQL on a practice dataset you generate yourself.
Topics in this module
- Reading CSV, JSON, Parquet, and Delta
- Schema Inference and Explicit Schemas
- DataFrame Creation
- DataFrame Transformations
- Filtering, Sorting, and Aggregation
- Joins and Window Functions
- Null Handling and Deduplication
- Built-in Spark SQL Functions
- Temporary Views and Tables
- DataFrame Writing and Save Modes
- SQL vs PySpark Workflows
First real PySpark. One cell generates a small practice dataset in a volume. Later modules reuse it.
- 3 datasets: customers (CSV), products (JSON), orders (Parquet)
- Deterministic: everyone gets the same rows, so counts in the notes match yours
- Use one notebook for the module, e.g.
04-working-with-data. Cells build on each other - Session restarted? Rerun the setup cell and the reload cell in topic 4.2
Run it in Databricks: create the practice data
- Create the notebook, paste the cell below, run it once
- Run the second cell to see what it wrote
display(dbutils.fs.ls(base))
- Spark writes a folder of part files per dataset, not one file
Quota
Small on purpose. To start over, rerun the setup cell: it overwrites the files.
Reading CSV, JSON, Parquet, and Delta
By the end: read CSV, JSON, Parquet and Delta data into DataFrames and know which reader to use.
| Format | Reader | Notes |
|---|---|---|
| CSV | spark.read.csv(path) |
Text, no types. Needs header, schema |
| JSON | spark.read.json(path) |
One object per line. Infers types |
| Parquet | spark.read.parquet(path) |
Columnar, carries its schema |
| Delta | spark.read.table(name) |
Table + transaction log. Databricks default |
- Generic form:
spark.read.format("...").load(path)
customers_csv = spark.read.option("header", True).csv(f"{base}/customers_csv")
products_json = spark.read.json(f"{base}/products_json")
orders_parquet = spark.read.parquet(f"{base}/orders_parquet")
for name, df in [("customers", customers_csv), ("products", products_json), ("orders", orders_parquet)]:
print(name, df.count(), "rows")
customers 200 rows products 30 rows orders 2040 rows
- 2,040 orders = 2,000 + 40 duplicates (removed in 4.7)
orders_parquet.write.mode("overwrite").saveAsTable("orders_raw")
spark.read.table("orders_raw").printSchema()
USE SCHEMAin setup putorders_rawindbx_course. See it in Catalog
Parquet or Delta over CSV
CSV is fine to receive, but untyped and slow. Convert to Delta early (bronze layer, Module 8).
Schema Inference and Explicit Schemas
By the end: control column types yourself with an explicit schema instead of relying on inference.
spark.read.option("header", True).csv(f"{base}/customers_csv").printSchema()
root |-- customer_id: string (nullable = true) |-- name: string (nullable = true) |-- country: string (nullable = true) |-- signup_date: string (nullable = true)
DDL string is the simplest explicit schema:
customers = (spark.read
.schema("customer_id INT, name STRING, country STRING, signup_date DATE")
.option("header", True)
.csv(f"{base}/customers_csv"))
customers.printSchema()
root |-- customer_id: integer (nullable = true) |-- name: string (nullable = true) |-- country: string (nullable = true) |-- signup_date: date (nullable = true)
- Need nullability or nested types? Use
StructType([StructField(name, type, nullable), ...])
Reload all three. Rerun this cell after every session restart:
customers = (spark.read
.schema("customer_id INT, name STRING, country STRING, signup_date DATE")
.option("header", True).csv(f"{base}/customers_csv"))
products = (spark.read
.schema("product_id INT, product_name STRING, category STRING, unit_price DECIMAL(8,2)")
.json(f"{base}/products_json"))
orders = spark.read.parquet(f"{base}/orders_parquet")
Bad rows
Default PERMISSIVE turns misfit values into NULL. Use mode: FAILFAST to stop, DROPMALFORMED to skip. Rescued data: Module 7.
DataFrame Creation
By the end: create DataFrames from Python data, pandas, SQL and tables.
from pyspark.sql import Row
import pandas as pd
df_a = spark.createDataFrame([(1, "Ada", "NL"), (2, "Grace", "DE")], "id INT, name STRING, country STRING")
df_b = spark.createDataFrame([Row(id=3, name="Linus", country="FI")]) # schema inferred
df_c = spark.createDataFrame(pd.DataFrame({"id": [4], "name": ["Margaret"], "country": ["US"]}))
df_d = spark.range(5)
df_e = spark.sql("SELECT 1 AS one, 'a' AS letter")
df_f = spark.read.table("orders_raw")
df_a.unionByName(df_b).show()
+---+-----+-------+ | id| name|country| +---+-----+-------+ | 1| Ada| NL| | 2|Grace| DE| | 3|Linus| FI| +---+-----+-------+
Pass a schema
Inferred types surprise you, e.g. whole numbers containing a None.
Serverless limit
Local rows passed to createDataFrame can't exceed 128 MB. Use spark.range, SQL or files for big data.
DataFrame Transformations
By the end: add, rename, cast and drop columns with chainable transformations.
- Transformations return a new DataFrame, so chain them. One step per line
from pyspark.sql import functions as F
orders_t = (
orders
.withColumn("order_date", F.to_date("order_ts"))
.withColumn("size", F.when(F.col("quantity") >= 4, "bulk").otherwise("regular"))
.withColumnRenamed("status", "order_status")
.select("order_id", "customer_id", "product_id", "quantity",
"size", "order_status", "order_date")
)
display(orders_t.limit(10))
| Task | Code |
|---|---|
| New / changed column | .withColumn("new", expr) |
| Several at once | .withColumns({"a": e1, "b": e2}) |
| Rename | .withColumnRenamed("old", "new") |
| Cast | F.col("x").cast("int") |
| Choose | .select("a", "b"), .selectExpr("b * 2 AS b2") |
| Remove | .drop("a") |
| Condition | F.when(cond, v).otherwise(o) |
| Constant | F.lit(5) |
Prefer column expressions
Built-in functions over Python lambdas, so Spark can optimise. Avoid withColumn in long loops (each adds a plan step): use withColumns or one select.
Filtering, Sorting, and Aggregation
By the end: filter, sort and aggregate data, and avoid the usual Python precedence trap.
filter=where. Combine with&,|,~- Wrap every condition in parentheses:
&binds tighter than>=and==
completed = orders.filter(F.col("status") == "completed")
bulk_or_shipped = orders.where(
(F.col("quantity") >= 4) & (F.col("status").isin("completed", "shipped"))
)
recent = orders.filter(F.col("order_ts") >= "2025-06-01").orderBy(F.col("order_ts").desc()).limit(5)
display(recent)
summary = (
orders.groupBy("status")
.agg(F.count("*").alias("orders"),
F.round(F.avg("quantity"), 2).alias("avg_quantity"),
F.countDistinct("customer_id").alias("customers"))
.orderBy(F.desc("orders"))
)
summary.show()
- Expect 4 rows,
completedon top (the generator makes it 3x as likely) - Counts are slightly high: duplicates are still in (fixed in 4.7)
| Function | Counts |
|---|---|
F.count("*") |
Rows, including duplicates and nulls |
F.count("col") |
Non-null values |
F.countDistinct("col") |
Different non-null values |
sum, avg, min, max |
Ignore nulls |
Parentheses and None
F.col("a") > 1 & F.col("b") < 5 raises an ambiguous-truth-value error. Write (...) & (...). Null test: isNull(), never == None.
Joins and Window Functions
By the end: join tables, rank rows with window functions and choose the right join type.
- Join on a column name when both sides share it: one copy of the key is kept
# dedupe first so revenue isn't counted twice
order_lines = (
orders.dropDuplicates(["order_id"])
.join(products, "product_id", "left")
.withColumn("revenue", F.col("quantity") * F.col("unit_price"))
)
by_category = (order_lines.groupBy("category")
.agg(F.round(F.sum("revenue"), 2).alias("revenue"))
.orderBy(F.desc("revenue")))
by_category.show()
- Also
right,full,cross(every combination: careful)
no_orders = customers.join(orders.select("customer_id").distinct(), "customer_id", "left_anti")
print(no_orders.count()) # may be 0: every customer ordered at least once
from pyspark.sql.functions import broadcast
joined = orders.join(broadcast(products), "product_id") # small table sent to every executor
Window functions: a value per row from related rows, without collapsing them.
from pyspark.sql.window import Window
product_revenue = (order_lines
.groupBy("category", "product_id", "product_name")
.agg(F.sum("revenue").alias("revenue")))
w_rank = Window.partitionBy("category").orderBy(F.desc("revenue"))
top3 = (product_revenue
.withColumn("rank", F.row_number().over(w_rank))
.filter(F.col("rank") <= 3))
display(top3.orderBy("category", "rank"))
w_run = (Window.partitionBy("customer_id").orderBy("order_ts")
.rowsBetween(Window.unboundedPreceding, Window.currentRow))
running = order_lines.withColumn("running_revenue", F.sum("revenue").over(w_run))
| Function | Gives |
|---|---|
row_number() |
Unique position in the partition |
rank(), dense_rank() |
Ties share a rank |
lag(), lead() |
Previous / next row's value |
sum().over(w) |
Running or grouped total |
Joins can multiply rows
Duplicate keys on the right repeat left rows. Dedupe or aggregate first, and compare counts before and after.
Null Handling and Deduplication
By the end: find missing values and duplicates, then decide whether to fill, drop or keep them.
null_counts = orders.select(
[F.count(F.when(F.col(c).isNull(), c)).alias(c) for c in orders.columns]
)
null_counts.show()
+--------+-----------+----------+--------+------+--------+ |order_id|customer_id|product_id|quantity|status|order_ts| +--------+-----------+----------+--------+------+--------+ | 0| 20| 0| 15| 0| 0| +--------+-----------+----------+--------+------+--------+
- Missing quantity may default to 1. A missing customer is probably a problem to investigate
- Don't fill values just to make errors disappear
Duplicates: 2,040 rows but 2,000 different order numbers.
print(orders.count(), orders.select("order_id").distinct().count())
print(orders.distinct().count(), orders.dropDuplicates(["order_id"]).count())
2040 2000 2000 2000
distinct(): rows identical in every columndropDuplicates([cols]): compares only those columns, keeps an arbitrary row- Keep the latest per key: window +
row_number
from pyspark.sql.window import Window
w = Window.partitionBy("order_id").orderBy(F.desc("order_ts"))
latest = (orders.withColumn("rn", F.row_number().over(w))
.filter("rn = 1")
.drop("rn"))
ANSI mode
Serverless divides by zero with an error, not NULL. Use F.expr("try_divide(revenue, quantity)").
Built-in Spark SQL Functions
By the end: use the built-in function families and avoid Python UDFs when a built-in exists.
| Family | Examples |
|---|---|
| Strings | upper, trim, substring, concat_ws, regexp_extract, split, lpad |
| Dates | to_date, to_timestamp, date_trunc, date_add, datediff, date_format |
| Numbers | round, floor, ceil, abs, pmod |
| Conditions, nulls | when, coalesce, nullif, isnull |
| Arrays, maps | array, explode, collect_list, element_at, array_contains |
| Aggregates | sum, avg, count, countDistinct, approx_count_distinct |
display(
customers.select(
"customer_id",
F.upper("country").alias("country_uc"),
F.lpad(F.col("customer_id").cast("string"), 5, "0").alias("customer_code"),
F.concat_ws(" | ", "name", "country").alias("label"),
F.year("signup_date").alias("signup_year"),
F.date_format("signup_date", "yyyy-MM").alias("signup_month"),
F.datediff(F.current_date(), "signup_date").alias("days_since_signup"),
).limit(10)
)
tags = spark.createDataFrame([(1, "a,b,c"), (2, "d")], "id INT, tags STRING")
display(tags.select("id", F.explode(F.split("tags", ",")).alias("tag"))) # list -> rows
monthly = (orders.withColumn("month", F.date_trunc("month", "order_ts"))
.groupBy("month").count().orderBy("month")) # day -> month
display(monthly)
UDFs are the last resort
A Python UDF ships every row to Python and back, and Spark can't optimise it. Built-in first, then pandas_udf (batches).
Temporary Views and Tables
By the end: query DataFrames with SQL through temporary views and know what a temp view is and is not.
orders.createOrReplaceTempView("orders_v")
customers.createOrReplaceTempView("customers_v")
%sql
SELECT c.country,
COUNT(DISTINCT o.order_id) AS orders
FROM orders_v o
JOIN customers_v c USING (customer_id)
GROUP BY c.country
ORDER BY orders DESC
%sql
CREATE OR REPLACE TEMP VIEW big_orders AS
SELECT * FROM orders_v WHERE quantity >= 4
big = spark.table("big_orders") # a DataFrame again
| Temp view | Table / view in Unity Catalog | |
|---|---|---|
| Lifetime | Current session | Until dropped |
| Visible to | Your session | Anyone with permission |
| Stores data | No | Table yes, view no |
| Create | createOrReplaceTempView |
saveAsTable, CREATE VIEW |
Serverless
No global temp views. Temp views aren't shared with a notebook: others must run the cells that create them.
DataFrame Writing and Save Modes
By the end: write DataFrames as Delta tables and files with the right save mode.
clean = orders.dropDuplicates(["order_id"])
clean.write.mode("overwrite").saveAsTable("orders")
print("after overwrite:", spark.table("orders").count())
clean.filter("order_id <= 10").write.mode("append").saveAsTable("orders")
print("after append: ", spark.table("orders").count())
clean.write.mode("overwrite").saveAsTable("orders") # restore
print("after restore: ", spark.table("orders").count())
after overwrite: 2000 after append: 2010 after restore: 2000
try:
clean.write.mode("errorifexists").saveAsTable("orders")
except Exception as e:
print("table exists:", str(e).splitlines()[0])
# later topics and modules query these
customers.write.mode("overwrite").saveAsTable("customers")
products.write.mode("overwrite").saveAsTable("products")
# plain files: a folder of part files
clean.write.mode("overwrite").parquet(f"{base}/orders_clean_parquet")
- Delta is the default table format: no format option needed
- Schema changed?
overwriteon an existing Delta table fails: add.option("overwriteSchema", "true")when intended
overwrite replaces everything
Not just the rows you pass. To change some rows use MERGE (Module 5). Avoid coalesce(1) on big data: one task writes it all.
Tables over files
Delta tables in Unity Catalog have schemas, history, permissions. Files only for exports and received data (Modules 5, 12).
SQL vs PySpark Workflows
By the end: choose SQL or PySpark for a task and mix them without friction.
- Same question both ways: top 5 customers by revenue
%sql
SELECT o.customer_id,
ROUND(SUM(o.quantity * p.unit_price), 2) AS revenue
FROM orders o
JOIN products p USING (product_id)
WHERE o.customer_id IS NOT NULL
GROUP BY o.customer_id
ORDER BY revenue DESC
LIMIT 5
top5 = (spark.table("orders")
.join(spark.table("products"), "product_id")
.where(F.col("customer_id").isNotNull())
.groupBy("customer_id")
.agg(F.round(F.sum(F.col("quantity") * F.col("unit_price")), 2).alias("revenue"))
.orderBy(F.desc("revenue"))
.limit(5))
display(top5)
Mix: register the DataFrame, continue in SQL.
top5.createOrReplaceTempView("top5")
%sql
SELECT t.customer_id, c.name, c.country, t.revenue
FROM top5 t
JOIN customers c USING (customer_id)
ORDER BY t.revenue DESC
- A finished SQL cell's result is also in
_sqldf - Values from Python into SQL: parameter markers, not f-strings
display(spark.sql(
"SELECT * FROM customers WHERE country = :country",
args={"country": "NL"},
))
One style per step
Use temp views or tables as the handoff between steps, not both languages in one step.
What you can do now
- Generated the practice data, read CSV, JSON, Parquet, Delta
- Explicit schemas, hand-built DataFrames, chained transformations
- Filter, aggregate, join, window, clean nulls and duplicates
- SQL and PySpark on the same data via temp views and tables
- Saved
orders,customers,productsas Delta tables indbx_coursefor Module 5