DatabricksZero to Advanced
Course/Level 8 · Small Project: Build It and Run It with Databricks Jobs

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.

7 hands-on2 with caveats9 topics
Topics in this module
  1. Project Goal and Data
  2. Project Layout for Databricks
  3. Transformations as Testable Functions
  4. Configuration and Environments
  5. Entry Points and Job Parameters
  6. Testing with pytest and Databricks Connect
  7. Building and Deploying a Wheel
  8. Git Folders and the Deployment Flow
  9. Logging and Error Handling

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:
Python
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)
26.01Hands-on on Free Edition

Project Goal and Data

By the end: define what the project builds and which Databricks pieces it touches.

orders_raw + productsload_silvercheck_qualitybuild_gold
Three job tasks, one wheel. Tables in Unity Catalog.
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)

26.02Hands-on on Free Edition

Project Layout for Databricks

By the end: lay out the repository so Databricks can build and run it.

Text
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 pyspark in 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)
26.03Hands-on on Free Edition

Transformations as Testable Functions

By the end: write transformations as small functions that take and return DataFrames.

Python
# 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, no saveAsTable, 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
26.04Hands-on on Free Edition

Configuration and Environments

By the end: handle configuration and environments without hard-coding.

Python
# 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-date and --run_date are accepted because job systems format keys differently (topic 5)
  • parse_known_args ignores extra arguments the job adds
  • An empty run_date means today (UTC). Pass a date explicitly to rerun a day
26.05Hands-on on Free Edition

Entry Points and Job Parameters

By the end: create one entry point per job task and read the job's parameters.

Python
# 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")))
toml
# 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.py entry_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 matches parse_args
  • A Python script task also receives parameters as arguments
  • build_gold rewrites 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
26.06Hands-on with caveats

Testing with pytest and Databricks Connect

By the end: run pytest on your computer against Databricks compute with Databricks Connect.

Python
# 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()
Python
# 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"
Shell
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() (or serverless_compute_id = auto in 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 pyspark and use the local fallback above, or run pytest as 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.

26.07Hands-on on Free Edition

Building and Deploying a Wheel

By the end: build the wheel and make it available to jobs.

Shell
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
Python
%pip install /Volumes/main/dbx_project/artifacts/orders_pipeline-0.1.0-py3-none-any.whl
Python
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
26.08Hands-on with caveats

Git Folders and the Deployment Flow

By the end: connect Git to Databricks and choose a deployment flow.

BranchGit folderdevMergeTagJobprod
Dev runs from a branch. Prod runs a tag or a built wheel.
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
26.09Hands-on on Free Edition

Logging and Error Handling

By the end: log what happened and fail in a way the job can see.

Python
# 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,
    )
Python
# 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)
Recap

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