A working project layout for a Python library that powers SQL functions in Unity Catalog — plus the caveats that bite you in production.
The idea in one line: the UDF body is a shim, the wheel is the library.
A Unity Catalog Python UDF body isn't a module. For scalar UDFs, the $$...$$ block is the function body — you write a bare return with no def. Logic written there can't be unit tested, can't be versioned, and can't be reused. So push everything into a wheel on a UC volume and keep the SQL to two lines:
CREATE OR REPLACE FUNCTION main.text_fns.mask_email(email STRING)
RETURNS STRING
LANGUAGE PYTHON
DETERMINISTIC
COMMENT 'acme_udf 1.0.0 - masks the local part of an email, preserves domain.'
ENVIRONMENT (
dependencies = '["/Volumes/main/udf_libs/wheels/acme_udf/1.0.0/acme_udf-1.0.0-py3-none-any.whl"]',
environment_version = '3'
)
AS $$
from acme_udf.text import mask_email
return mask_email(email)
$$;Important
Unity Catalog UDFs and custom dependencies are both Public Preview. Fine for internal use — flag it before putting a customer's critical path on it.
├── pyproject.toml # name, version, deps — the single source of version truth
├── src/acme_udf/
│ ├── __init__.py # re-export entrypoints ONLY; keep imports cheap
│ ├── text.py # pure scalar functions
│ ├── tokenize.py # scalar + pandas Series form for batch UDFs
│ └── _data/ # package data, read via importlib.resources
├── tests/ # plain pytest — no Spark, no Databricks
├── udfs/ # one .sql template per function = deployable contract
├── scripts/
│ ├── render_ddl.py # injects version + volume path into the templates
│ └── deploy_udfs.py # runs CREATE OR REPLACE via Statement Execution API
├── databricks.yml # builds the wheel, uploads it to the volume
└── .github/workflows/ # ci: test+build+lint | release: deploy+register+smoke
Volume layout — version in the path, never overwrite:
/Volumes/<catalog>/udf_libs/wheels/acme_udf/1.0.0/acme_udf-1.0.0-py3-none-any.whl
Put UDFs in their own catalog/schema with their own grants. Databricks recommends this explicitly, and it keeps EXECUTE grants separate from data grants.
pip install -e '.[dev]'
pytest
# Render DDL from pyproject version + your volume path
python scripts/render_ddl.py \
--catalog main --schema text_fns \
--volume /Volumes/main/udf_libs/wheels \
--out build/ddl
python scripts/deploy_udfs.py --dry-run --ddl-dir build/ddl # inspect
python scripts/deploy_udfs.py --profile my-ws \
--warehouse-id <id> --ddl-dir build/ddl # registerThen grant access — both of these, see the gotcha below:
GRANT EXECUTE ON FUNCTION main.text_fns.mask_email TO `data_users`;
GRANT READ VOLUME ON VOLUME main.udf_libs.wheels TO `data_users`;- Architecture is not stable. A serverless SQL warehouse runs on
aarch64orx86_64, and it can change between restarts. A wheel with native C extensions built for one arch fails on the other withISOLATION_ENVIRONMENT_USER_ERROR.GENERIC. Ship both variants gated byplatform_machinemarkers —render_ddl.py --nativeemits exactly that. Strongly prefer pure-Python (py3-none-any) and make it a design constraint; CI asserts it. environment_version = '3'pins Python 3.12.3 and a fixed preinstalled package set, independent of the DBR. Build against that interpreter. Values'3'+ work only on serverless compute and serverless SQL warehouses — on classic compute pass--environment-version None.- Never list
pysparkordatabricks-connectas dependencies; they collide with the sandbox runtime. CI asserts this too. - Keep the dependency closure small. Deps resolve when the isolation environment is built, so every transitive package is cold-start latency on the first invocation.
- Immutable filenames. Never reupload a different build to the same wheel path — bump the version and re-run
CREATE OR REPLACE. Overwriting in place leaves you no way to reason about what a warehouse actually loaded.
- No filesystem, no
SparkContext, no internal services. The library can't read config files, write temp files, or call back into Spark. Pure functions only; bundle lookup data as package data and read it withimportlib.resources(seetokenize.py). - Module-level mutable state is a trap. Since DBR 18.0 (scalar) / 17.1 (batch), UDFs with the same owner + session share an isolation environment by default. If a module mutates globals, writes files, touches env vars, or
evals input, you must addSTRICT ISOLATION— otherwise you get cross-UDF interference. Better: design so you never need it. Module-level immutable state (compiled regexes, loaded tables) is correct and desirable — it's paid once per environment, not once per row. - Import cost is per-environment. Do expensive one-time init at module scope, not inside the called function.
- Max 5 UDFs per query. A hard planner limit that shapes how you factor functions: one
enrich_recordreturning aSTRUCTbeats five single-field UDFs. Heavy plans can also hitUDF_MAX_COUNT_EXCEEDED. - NULLs are yours to handle. No implicit null propagation — guard every input. Every function here returns
NoneforNone. - Scalar returns only for standard UC Python UDFs.
- Views containing a UC Python UDF fail on classic SQL warehouses. Serverless or pro only.
- Row-at-a-time is slow. Past toy volume, use Batch UC Python UDFs (
PARAMETER STYLE PANDAS+HANDLER, DBR 16.3+). The handler must yield exactly as many rows as it received — design the library with a Series-in/Series-out form from day one rather than retrofitting it. Seetokenize_pii.sql. - Declare
DETERMINISTICwhere true; the default assumption is non-deterministic and you lose optimizations. - Lineage goes opaque through UDF bodies — don't hide join or filter logic in there.
EXECUTEon the function is not sufficient. The invoking user needsREAD VOLUMEon the wheel's volume. This is the caveat that bites teams: you ship a governed function and callers still get permission errors. Either grantREAD VOLUMEbroadly on a dedicated read-onlyudf_libsvolume, or accept the coupling. (Contrast service credentials, which do run as the creator.) The release workflow smoke-tests a real invocation to catch this.- Service credentials are unavailable in standard UC Python UDFs — only Batch UC Python UDFs and scalar Python UDFs. Inside a UDF the API is
databricks.service_credentials.getServiceCredentialsProvider(), notdbutils.credentials.*. - Network egress: UDFs get TCP/UDP on ports 80/443/53. Installing deps from public URLs on a serverless SQL warehouse needs the Enable networking for isolated workloads in Serverless SQL Warehouses preview enabled; serverless notebook/job compute needs egress policies configured. Volume-hosted wheels avoid this entirely — another reason to prefer them over PyPI.
- No DABs resource for functions. Bundles build and upload the wheel (
artifacts: type: whl,workspace.artifact_path: /Volumes/...), but there's noresources.functions— registration must be a post-deploy job or script step. Also:artifact_pathcan't be a DBFS path, and the volume must exist before you can point at it.
| Capability | Minimum |
|---|---|
| UC Python UDFs | Serverless/pro SQL warehouse, or DBR 13.3 LTS+ |
Custom dependencies (ENVIRONMENT) |
Serverless, pro SQL warehouse, or DBR 16.2+ classic |
| Batch UC Python UDFs | DBR 16.3+ |
| Shared isolation environments | DBR 18.0 (scalar) / 17.1 (batch) |
Creating functions needs USAGE + CREATE on the schema and USAGE on the catalog; running them needs EXECUTE plus USAGE on schema and catalog.
- SQL and Python UDFs in Unity Catalog
- Batch Python UDFs in Unity Catalog
- Serverless environment versions
- CREATE FUNCTION reference
- pip requirements file format · environment markers
Apache 2.0