DatabricksZero to Advanced

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

  1. Create the notebook, paste the cell below, run it once
  2. Run the second cell to see what it wrote
Python
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")
Python
display(dbutils.fs.ls(base))
customersCSV with headercustomer_idnamecountrysignup_dateproductsJSON linesproduct_idproduct_namecategoryunit_priceordersParquetorder_idcustomer_idproduct_idquantitystatusorder_ts
200 customers, 30 products, 2,040 orders (40 duplicates, 20 null customer_id, 15 null quantity).
  • 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.

4.01Hands-on on Free Edition

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)
Python
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")
Output
customers 200 rows
products 30 rows
orders 2040 rows
  • 2,040 orders = 2,000 + 40 duplicates (removed in 4.7)
Python
orders_parquet.write.mode("overwrite").saveAsTable("orders_raw")
spark.read.table("orders_raw").printSchema()
  • USE SCHEMA in setup put orders_raw in dbx_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).

4.02Hands-on on Free Edition

Schema Inference and Explicit Schemas

By the end: control column types yourself with an explicit schema instead of relying on inference.

Defaultall CSV columns are stringsinferSchemaextra pass over dataguess can changeExplicit schemafasterstableyou decide types
Python
spark.read.option("header", True).csv(f"{base}/customers_csv").printSchema()
Output
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:

Python
customers = (spark.read
    .schema("customer_id INT, name STRING, country STRING, signup_date DATE")
    .option("header", True)
    .csv(f"{base}/customers_csv"))
customers.printSchema()
Output
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:

Python
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.

4.03Hands-on on Free Edition

DataFrame Creation

By the end: create DataFrames from Python data, pandas, SQL and tables.

Python datalist of tuples + DDLRow objectspandas DataFrameSparkspark.range(n)spark.sql(...)spark.read.table(...)
Python
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()
Output
+---+-----+-------+
| 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.

4.04Hands-on on Free Edition

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
Python
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.

4.05Hands-on on Free Edition

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 ==
Python
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)
Python
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, completed on 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.

4.06Hands-on on Free Edition

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
Python
# 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()
innermatch on both sidesleftall left rowsleft_semihas a matchno right columnsleft_antihas no match
  • Also right, full, cross (every combination: careful)
Python
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.

Python
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.

4.07Hands-on on Free Edition

Null Handling and Deduplication

By the end: find missing values and duplicates, then decide whether to fill, drop or keep them.

Python
null_counts = orders.select(
    [F.count(F.when(F.col(c).isNull(), c)).alias(c) for c in orders.columns]
)
null_counts.show()
Output
+--------+-----------+----------+--------+------+--------+
|order_id|customer_id|product_id|quantity|status|order_ts|
+--------+-----------+----------+--------+------+--------+
|       0|         20|         0|      15|     0|       0|
+--------+-----------+----------+--------+------+--------+
Fillfillna({"quantity": 1})coalesce(col, lit(-1))Dropdropna(subset=[...])Keep and investigatemissing customer = data problem
  • 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.

Python
print(orders.count(), orders.select("order_id").distinct().count())
print(orders.distinct().count(), orders.dropDuplicates(["order_id"]).count())
Output
2040 2000
2000 2000
  • distinct(): rows identical in every column
  • dropDuplicates([cols]): compares only those columns, keeps an arbitrary row
  • Keep the latest per key: window + row_number
Python
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)").

4.08Hands-on on Free Edition

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
Python
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)
)
Python
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).

4.09Hands-on on Free Edition

Temporary Views and Tables

By the end: query DataFrames with SQL through temporary views and know what a temp view is and is not.

DataFramename itTemp viewsession onlyquerySQL resultbackDataFrame
A temp view is a saved name for a query, not stored data.
Python
orders.createOrReplaceTempView("orders_v")
customers.createOrReplaceTempView("customers_v")
SQL
%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
%sql
CREATE OR REPLACE TEMP VIEW big_orders AS
SELECT * FROM orders_v WHERE quantity >= 4
Python
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.

4.10Hands-on on Free Edition

DataFrame Writing and Save Modes

By the end: write DataFrames as Delta tables and files with the right save mode.

errorifexistsdefaultfails if target existsappendadds new rowsoverwritereplaces everythingignoredoes nothing if target exists
Python
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())
Output
after overwrite: 2000
after append:    2010
after restore:   2000
Python
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? overwrite on 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).

4.11Hands-on on Free Edition

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
%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
Python
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.

Python
top5.createOrReplaceTempView("top5")
SQL
%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
Python
display(spark.sql(
    "SELECT * FROM customers WHERE country = :country",
    args={"country": "NL"},
))
SQLset-based queriesexplorationdashboardsdeclarative pipelinesPySparkreusable functionsloops over tablesunit-tested code

One style per step

Use temp views or tables as the handoff between steps, not both languages in one step.

Recap

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, products as Delta tables in dbx_course for Module 5