catalog = spark.sql("SELECT current_catalog()").first()[0]
spark.sql(f"CREATE SCHEMA IF NOT EXISTS {catalog}.dbx_project")
spark.sql(f"CREATE VOLUME IF NOT EXISTS {catalog}.dbx_project.artifacts") # for the wheel (topic 7)
print(catalog)
Module 26: Python Project on Databricks
Structure the small project for Databricks: layout, config, entry points, tests, wheel and Git folders. Assumes you know Python and Git.
Topics in this module
Turn a PySpark script into a small Python project that Databricks can run. This module only covers the Databricks side. You already know Python packaging, pytest and Git.
- The project:
orders_pipeline, three steps over the practice data (Module 4):load_silver→check_quality→build_gold - Module 27 runs it as a job. Module 28 runs the same job from Airflow
- Free Edition: all notebooks and jobs work. Anything needing a Git provider, the CLI or Databricks Connect needs credentials from your workspace: check Settings → Developer first. The docs don't say if Free Edition allows them, so each topic has a fallback
- Tables go in a schema
dbx_project. Create it once:
Project Goal and Data
By the end: define what the project builds and which Databricks pieces it touches.
| Step | Reads | Writes |
|---|---|---|
load_silver |
dbx_course.orders_raw |
silver_orders (deduped, typed, bad rows dropped) |
check_quality |
silver_orders |
Fails the task if rules break |
build_gold |
silver_orders, dbx_course.products |
gold_daily_revenue for the last N days |
What makes it production-shaped:
- Parameters:
catalog,schema,run_date,days. Nothing hard-coded - Idempotent: a rerun of the same date gives the same result (
replaceWhere) - Code lives in a package; notebooks only call it
- Tests run without a cluster where possible
Databricks pieces: Unity Catalog tables, a volume for the wheel, serverless jobs (Module 27)
Project Layout for Databricks
By the end: lay out the repository so Databricks can build and run it.
orders_pipeline/
├── pyproject.toml
├── src/orders_pipeline/
│ ├── __init__.py
│ ├── config.py # parameters -> Config
│ ├── transforms.py # pure DataFrame functions
│ └── main.py # entry points (one per job task)
├── tests/
│ ├── conftest.py
│ └── test_transforms.py
├── databricks.yml # bundle + job definition (Module 27)
└── .github/workflows/ # CI (Module 27)
src/layout: tests import the installed package, as the job will- The runtime already has PySpark. Don't list
pysparkin dependencies - Notebooks are thin:
from orders_pipeline.main import ..., nothing else
Getting code into Databricks, three ways:
| Way | Use when | Needs |
|---|---|---|
| Wheel in a volume | Production runs | Build on your computer, upload |
| Git folder | Developing, running scripts from a branch | Git provider account |
| Workspace files | Quick start without Git | Create files in the workspace editor |
- Free Edition: the volume wheel path needs no Git and no CLI. Upload through Catalog (Module 3)
Transformations as Testable Functions
By the end: write transformations as small functions that take and return DataFrames.
# src/orders_pipeline/transforms.py
from pyspark.sql import DataFrame, functions as F
def clean_orders(df: DataFrame) -> DataFrame:
"""Typed, lower-case status, no invalid rows, one row per order."""
return (
df.withColumn("status", F.lower(F.trim("status")))
.withColumn("order_date", F.to_date("order_ts"))
.filter("customer_id IS NOT NULL AND quantity > 0")
.dropDuplicates(["order_id"])
)
def daily_revenue(orders: DataFrame, products: DataFrame) -> DataFrame:
return (
orders.filter("status IN ('completed', 'shipped')")
.join(products.select("product_id", "unit_price"), "product_id")
.groupBy("order_date")
.agg(F.count("*").alias("orders"),
F.round(F.sum(F.col("quantity") * F.col("unit_price")), 2).alias("revenue"))
)
- No
spark.table, nosaveAsTable, no widgets inside: inputs in, DataFrame out - Reading and writing sit in
main.py. This is what makes unit tests possible (Module 14) - Keep column names as constants if more than two files use them
Configuration and Environments
By the end: handle configuration and environments without hard-coding.
# src/orders_pipeline/config.py
import argparse
from dataclasses import dataclass
from datetime import date, timedelta
@dataclass(frozen=True)
class Config:
catalog: str
schema: str
run_date: str
days: int
def table(self, name: str) -> str:
return f"{self.catalog}.{self.schema}.{name}"
@property
def window_start(self) -> str:
return (date.fromisoformat(self.run_date) - timedelta(days=self.days - 1)).isoformat()
def parse_args(argv=None) -> Config:
p = argparse.ArgumentParser()
p.add_argument("--catalog", required=True)
p.add_argument("--schema", default="dbx_project")
p.add_argument("--run-date", "--run_date", dest="run_date", default="")
p.add_argument("--days", type=int, default=7)
a, _unknown = p.parse_known_args(argv)
run_date = a.run_date or date.today().isoformat()
date.fromisoformat(run_date) # fail early on a bad date
return Config(a.catalog, a.schema, run_date, a.days)
| Environment | How to separate |
|---|---|
| dev / prod | Different schema (dbx_project_dev, dbx_project) on Free Edition. Paid workspaces: different catalogs |
| Settings that change | Job parameters, not code or files |
| Secrets | Secret scope, read in the entry point. Never in parameters (Module 17) |
- Both
--run-dateand--run_dateare accepted because job systems format keys differently (topic 5) parse_known_argsignores extra arguments the job adds- An empty
run_datemeans today (UTC). Pass a date explicitly to rerun a day
Entry Points and Job Parameters
By the end: create one entry point per job task and read the job's parameters.
# src/orders_pipeline/main.py
import logging
import sys
from pyspark.sql import SparkSession, functions as F
from .config import parse_args
from .transforms import clean_orders, daily_revenue
log = logging.getLogger("orders_pipeline")
def _spark() -> SparkSession:
return SparkSession.builder.getOrCreate()
def load_silver() -> None:
cfg = parse_args()
spark = _spark()
raw = spark.table(f"{cfg.catalog}.dbx_course.orders_raw")
clean_orders(raw).write.mode("overwrite").saveAsTable(cfg.table("silver_orders"))
log.info("silver_orders written for %s", cfg.run_date)
def check_quality() -> None:
cfg = parse_args()
silver = _spark().table(cfg.table("silver_orders"))
bad = silver.filter("order_id IS NULL OR quantity <= 0").count()
if bad:
raise ValueError(f"{bad} invalid rows in silver_orders")
def build_gold() -> None:
cfg = parse_args()
spark = _spark()
silver = (spark.table(cfg.table("silver_orders"))
.filter(F.col("order_date").between(cfg.window_start, cfg.run_date)))
products = spark.table(f"{cfg.catalog}.dbx_course.products")
out = daily_revenue(silver, products)
(out.write.mode("overwrite")
.option("replaceWhere", f"order_date >= '{cfg.window_start}' AND order_date <= '{cfg.run_date}'")
.saveAsTable(cfg.table("gold_daily_revenue")))
# pyproject.toml (fragment)
[project.scripts]
load_silver = "orders_pipeline.main:load_silver"
check_quality = "orders_pipeline.main:check_quality"
build_gold = "orders_pipeline.main:build_gold"
- A Python wheel task needs a package name and an entry point name. The docs' example defines them in
setup.pyentry_points: the[project.scripts]table above produces the same package metadata - Wheel tasks pass parameters as command-line arguments. Run once with
print(sys.argv)to see the exact format, then check it matchesparse_args - A Python script task also receives parameters as arguments
build_goldrewrites only the window[run_date - days + 1, run_date]: a rerun gives the same rows, other dates are untouched (Module 8.13)- Use a 2025 date with the practice data, for example
2025-03-15
Testing with pytest and Databricks Connect
By the end: run pytest on your computer against Databricks compute with Databricks Connect.
# tests/conftest.py
import pytest
@pytest.fixture(scope="session")
def spark():
try:
from databricks.connect import DatabricksSession
return DatabricksSession.builder.getOrCreate() # remote: serverless or cluster
except ImportError:
from pyspark.sql import SparkSession
return SparkSession.builder.master("local[1]").getOrCreate()
# tests/test_transforms.py
from pyspark.testing import assertDataFrameEqual
from orders_pipeline.transforms import clean_orders
def test_clean_orders_drops_invalid_and_dedupes(spark):
df = spark.createDataFrame(
[(1, 10, 2, " Shipped ", "2025-03-01 10:00:00"),
(1, 10, 2, "shipped", "2025-03-01 10:00:00"), # duplicate id
(2, None, 1, "completed", "2025-03-01 11:00:00"), # no customer
(3, 11, 0, "completed", "2025-03-01 12:00:00")], # bad quantity
"order_id INT, customer_id INT, quantity INT, status STRING, order_ts STRING")
out = clean_orders(df)
assert out.count() == 1
assert out.first()["status"] == "shipped"
pip uninstall -y pyspark # databricks-connect and pyspark conflict
pip install "databricks-connect==17.3.*" # match the compute version, see the docs
databricks auth login --host https://<workspace-url>
pytest -q
- Serverless Databricks Connect needs a workspace with serverless on, and a profile:
DatabricksSession.builder.serverless().getOrCreate()(orserverless_compute_id = autoin the profile) - A DataFrame test sends work to Databricks: it is slower than local Spark. Keep tests small
- Not supported over Connect: RDDs, most
dbutils,display(). On serverless with older runtimes, UDFs can't use custom libraries - Without Databricks Connect: install
pysparkand use the local fallback above, or runpytestas a job (Module 27)
Free Edition
Databricks Connect needs OAuth or a token. Community reports say OAuth login fails on Free Edition. Without a token, use local pyspark for tests.
Building and Deploying a Wheel
By the end: build the wheel and make it available to jobs.
python -m pip install build
python -m build --wheel # creates dist/orders_pipeline-0.1.0-py3-none-any.whl
| Step | Detail |
|---|---|
| Version | Bump version every release. A job that already installed 0.1.0 will not refetch a changed file with the same name |
| Store | A volume: /Volumes/<catalog>/dbx_project/artifacts/ |
| Try in a notebook | %pip install /Volumes/<catalog>/dbx_project/artifacts/orders_pipeline-0.1.0-py3-none-any.whl |
| Use in a job | Serverless: add the wheel path under the task's Environment and libraries |
%pip install /Volumes/main/dbx_project/artifacts/orders_pipeline-0.1.0-py3-none-any.whl
dbutils.library.restartPython()
from orders_pipeline.main import load_silver
- Upload with the Catalog UI, or
databricks fs cp dist/*.whl dbfs:/Volumes/<catalog>/dbx_project/artifacts/once the CLI works - Keep old versions in the volume: rollback = point the job at the old file
- Build in CI for production (Module 27.9), not on a laptop
Git Folders and the Deployment Flow
By the end: connect Git to Databricks and choose a deployment flow.
| Piece | What it does |
|---|---|
| Git folder | A workspace folder linked to a repo: pull, commit, push, switch branch from the UI |
| Job with a Git source | A job task runs a notebook, Python script or SQL file straight from a branch, tag or commit |
| Wheel from a volume | A built, versioned artifact. The job doesn't need Git access |
Git source facts (from the docs):
- Supported tasks: notebooks, Python scripts, SQL files, dbt. Python wheel tasks are not in that list
- Reference a branch, tag or commit (one of them). Every task in a run uses the same commit
- Tasks using a remote repo can't write to workspace files: write to volumes or tables
- Git credentials must be configured for the account (or the run-as identity)
| Approach | Good for |
|---|---|
| Script tasks from Git + tag | Simple projects, instant rollback by changing the tag |
| Wheel in a volume | Reusable library, several jobs sharing code |
- Never run production from a dev branch
- Free Edition: the Git folder needs an external Git account. Without one, use the volume wheel
Logging and Error Handling
By the end: log what happened and fail in a way the job can see.
# src/orders_pipeline/logging_setup.py
import logging
import sys
def setup_logging(level: int = logging.INFO) -> None:
logging.basicConfig(
level=level,
stream=sys.stdout,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
force=True,
)
# in main.py
import logging, time
from .logging_setup import setup_logging
def run_step(name, fn):
setup_logging()
log = logging.getLogger("orders_pipeline")
t0 = time.time()
try:
fn()
log.info("step=%s status=ok seconds=%.1f", name, time.time() - t0)
except Exception:
log.exception("step=%s status=failed", name)
raise # the job task must fail
- Raise so the task is marked failed. Swallowed errors give green runs and wrong data
- Log counts (rows read, written, rejected) and the parameters used
- Logs print to the run's output (stdout/stderr). Keep them short and structured:
key=value - Row-level run history goes to a table (
ops_run_log, Module 16) - Don't log secrets or whole rows (Module 17)
What you can do now
- Thin notebooks, a package, entry points per task
- Config from job parameters; environments by schema or catalog
- Test with pytest: local Spark or Databricks Connect
- Ship a versioned wheel in a volume, or run scripts from a Git tag
- Raise on failure and log counts