Databricks CI/CD: Git Folders, Declarative Automation Bundles and the Databricks CLI (Data Engineer Associate)
CI/CD on Databricks means developing data code on Git branches in Databricks Git folders, reviewing it through pull requests, and promoting the same codebase from dev to test to prod with Declarative Automation Bundles (formerly Databricks Asset Bundles) deployed by the Databricks CLI. This lesson covers branching, committing, pushing and pull requests in Git folders; merge versus rebase versus reset; the databricks.yml structure; bundle variables, their precedence, lookups and built-in substitutions; target overrides; development and production modes and run_as; and the CLI commands and flags a CI pipeline uses to validate, deploy, run and manage bundles.
On this page10 sections
- Git folders: branching in the workspace UI
- Committing, pushing and pull requests
- Merge, rebase, reset and pull: choosing the right operation
- Declarative Automation Bundles and the databricks.yml file
- Custom variables: one definition, a value per environment
- Lookup variables and built-in substitutions
- Targets and overrides: promoting one codebase to dev, test and prod
- Deployment modes, presets and the run_as identity
- The Databricks CLI bundle commands
- Running bundles in CI/CD pipelines
- Create and switch branches in a Git folder, and predict what happens to uncommitted edits and to assets missing from the target branch
- Commit and push from the Git dialog and take a change through a pull request in the Git provider, including updating an open pull request
- Choose between pull, merge, rebase and reset in a Git folder, and resolve or abort a conflicting merge
- Structure a bundle's databricks.yml with include, resources, variables, targets, sync and workspace mappings
- Supply environment-specific values with custom, complex and lookup variables and built-in substitutions, and work out which value wins
- Promote one codebase across dev, test and prod with target overrides, deployment modes, presets and a service principal run_as
- Use databricks bundle validate, deploy, run, summary, destroy, generate and deployment bind/unbind, with the right flags and identity in CI
Git folders: branching in the workspace UI
A Databricks Git folder (formerly Databricks Repos) is a workspace folder linked to a remote Git repository, and the Git dialog is where you branch, commit, push, pull, merge, rebase and reset without leaving the workspace. New work starts on a new branch: in the Git dialog choose Create Branch, give it a name and pick the base branch (usually main). The folder switches to the new branch, and work stays off main until it has been reviewed.
Switching branches is done from the branch dropdown in the same dialog. Three behaviours surprise people:
- Uncommitted changes carry over. Edits you have not committed travel to the branch you switch to and show there as uncommitted changes, unless they conflict with that branch. Git folders do not stash them for you. To keep the other branch clean, commit or discard first.
- Missing assets are deleted, then recreated with new IDs. If the target branch does not contain a notebook or file, switching removes it from the workspace. Switching back recreates it as a new object with a new ID and URL, and that cannot be undone, so bookmarks, shared links and anything else built on the old URL break.
- The branch belongs to the folder, not the person. A Git folder has one checked-out branch for everyone who opens it. If several engineers share one Git folder (for example under
/Workspace/Shared) and one of them switches branch, everyone else's view changes. The recommended pattern is for each developer to clone the repository into a Git folder under their own user folder and branch there; production jobs read code from Git or from a bundle deployment, never from someone's working folder.
Git operations run against the provider (GitHub, GitLab, Azure DevOps, Bitbucket and others) using the Git credential each user links in their own Databricks settings (a personal access token or the provider's app/OAuth integration). Provider permissions alone are therefore not enough: a user with write access in the provider still cannot push from a Git folder until they have linked their own credential. Workspace permissions on the folder do not replace it.
Committing, pushing and pull requests
To share work, open the Git dialog, review the highlighted changed files, enter a commit message (it is required) and choose Commit & Push. That single action commits the changes and pushes them to the remote branch, where teammates and reviewers can see them. Pull moves changes the other way, from the remote branch into your folder.
Notebook outputs. Notebooks in a Git folder are stored in source format by default (a .py or .sql file with cell markers), which holds code only, so Commit & Push never sends cell results. To version results in Git, the notebook must be stored as IPYNB and output commits must be enabled for it; running the cells or clearing state does not change what a source file can hold.
Pull requests live in the Git provider. Git folders handle cloning, branching, committing, pushing, pulling, merging, rebasing and resetting; the review happens in the provider. The flow is:
- Create a feature branch from main in the Git dialog (uncommitted edits made on main carry over to it).
- Commit & Push to the feature branch.
- Open a pull request from the feature branch to main in the Git provider; a reviewer approves it and it is merged there.
Most teams protect main in the provider: direct pushes are rejected and every change needs an approved pull request, so merging into main from the Git dialog or pushing from another account does not get around review. A pull request follows its source branch, not fixed commits: to address review comments, make the edits on the same branch and Commit & Push again, and the new commits appear in the existing pull request with the discussion intact. A new branch would need a new pull request.
Merge, rebase, reset and pull: choosing the right operation
Git folders offer four ways to change a branch's history, and the right one depends on whether you want to keep commits and whether anyone else shares the branch. The key detail is that rebase and reset both force push.
| Operation | What it does in a Git folder | Use it when |
|---|---|---|
| Pull | Brings the current branch up to date with its own remote branch | Teammates pushed to the branch you are on |
| Merge | Merges another branch (for example main) into the current one (typically with a merge commit) and pushes normally | The branch is shared: history others have pulled must not be rewritten |
| Rebase | Replays the branch's commits on top of the other branch, then commits and runs git push --force | You alone use the branch and want a linear history with no merge commits |
| Reset | Hard-resets the branch to another branch and force pushes, discarding local and remote commits | You want to throw away committed, pushed work on a branch nobody else depends on |
Merge and rebase both bring main's new commits into a feature branch; the constraint decides. Rebase rewrites commits that teammates may already have pulled, so on a shared branch use merge. When a linear history is required and nobody else has pulled the branch, rebase is the only operation that puts the feature's commits on top of main. Reset is destructive by design: it is the tool for making an experimental branch match main again, not for integrating changes.
Conflicts. When a merge or rebase hits conflicts, the UI lets you resolve each file by editing it, or choose Keep all current or Take all incoming, then Mark As Resolved and Continue Merge (or Continue Rebase). Abort is the escape hatch: it cancels the operation and undoes everything it changed, returning the folder to where it was before the merge or rebase began. It is the way out of any merge or rebase you no longer want to finish.
Declarative Automation Bundles and the databricks.yml file
Declarative Automation Bundles (formerly Databricks Asset Bundles) are Databricks' infrastructure-as-code format for CI/CD: a project folder whose YAML configuration describes Lakeflow Jobs, Lakeflow Spark Declarative Pipelines and other workspace assets alongside the code they run, kept in source control and deployed with the Databricks CLI to each environment. They solve the drift that comes from clicking jobs together separately in dev, test and prod workspaces. Git folders move code; bundles move code and the job and pipeline definitions.
Every bundle has one databricks.yml at its root. The top-level mappings you need to recognise:
| Mapping | Purpose |
|---|---|
bundle | The bundle's name (and optional settings such as the Git branch for checks) |
include | Extra YAML configuration files to load, for example resources/*.yml |
resources | Jobs, pipelines and other assets the bundle deploys, each under a resource key |
variables | Custom variables, with descriptions, defaults or lookups |
targets | Deployment environments (dev, test, prod) and their overrides |
workspace | Workspace host and paths (root_path, file_path, artifact_path, state_path) |
sync | Which project files are uploaded to the workspace on deploy |
artifacts | How to build packages such as Python wheels |
run_as, permissions, presets | Run identity, access to deployed resources, and deployment-wide adjustments |
Two near-twins are easy to confuse. The top-level include lists additional YAML files that form part of the bundle configuration, the usual way to split a large databricks.yml into one file per job or pipeline. sync.include is about uploads: on deploy the bundle uploads project files but skips anything matched by .gitignore, so sync.include adds back a generated file that must stay out of Git but is needed in the workspace, and sync.exclude drops further files.
To start a project, databricks bundle init asks a few questions and writes a starter bundle from a template (databricks.yml with targets, a sample job and code). Not every workspace object is a bundle resource: cluster policies, for example, are created by admins outside the bundle and are referenced by ID or lookup.
Custom variables: one definition, a value per environment
Bundle variables are how one configuration carries values that differ by environment. Declare a variable once under the top-level variables mapping (optionally with a default), reference it anywhere in the configuration as ${var.<name>}, and set its value per environment under each target's variables mapping:
variables:
catalog:
description: Catalog the jobs write to
default: dev_sales
targets:
test:
variables:
catalog: test_sales
prod:
variables:
catalog: sales
The job definition then uses ${var.catalog} once and never needs a copy per target. Substitutions in ${...} are resolved by the CLI at deployment. Do not confuse them with the {{...}} dynamic value references that Lakeflow Jobs resolves at run time (such as {{job.id}}); the bundle leaves those alone and passes them through to the job.
A target's values apply only to that target. Deploying the test target never reads values set under prod; if test sets nothing, the variable's default is used.
Complex variables. Ordinary variables hold single values such as strings or numbers. Declaring type: complex lets a variable hold a mapping or a list, for example a whole new_cluster definition (Spark version, node type, autoscale range, Spark configuration). Each job then references ${var.etl_cluster} in its own job_clusters entry: every job still gets its own job cluster, and one edit to the variable changes them all. A string variable cannot stand in for nested YAML.
Precedence. When a variable gets values from several places, the CLI uses the first source that provides one, in this order:
- The
--varoption on the command, for exampledatabricks bundle deploy -t test --var="catalog=qa_override" - An environment variable named
BUNDLE_VAR_<name>, for exampleBUNDLE_VAR_catalog - The target's
.databricks/bundle/<target>/variable-overrides.jsonfile - The target's
variablesmapping in the configuration - The variable's
default
Two consequences follow. A CI runner that exports BUNDLE_VAR_... silently beats both the overrides file and the target mapping, so editing those has no effect while the environment variable is set. And the only way to beat an environment variable for one run without touching the runner is --var on the command itself.
Lookup variables and built-in substitutions
Lookup variables and built-in substitutions supply environment-specific values without anyone typing them in. Lookup variables handle an object created outside the bundle (a cluster policy, SQL warehouse, cluster, instance pool, service principal, alert, dashboard, job, metastore, pipeline or query) that has the same name but a different ID in each workspace. Instead of recording each ID under its target, declare:
variables:
wh_id:
lookup:
warehouse: finance-sql
and reference ${var.wh_id}. At deployment the CLI resolves the name to the ID in whichever workspace the target deploys to, and fails if no single object matches. Writing per-target IDs also works but must be copied and maintained, and putting a display name where an ID is expected does not work at all.
Alongside custom and lookup variables, bundles provide built-in substitutions that need no declaration. The ones that matter most:
| Substitution | Resolves to | Typical use |
|---|---|---|
${bundle.target} | The target being deployed (from -t or the default target) | Per-environment names, such as events_load_${bundle.target} |
${bundle.name} | The bundle's name, the same in every target | Shared naming |
${workspace.current_user.userName} / short_name | The identity running the bundle command | Per-developer paths and schemas, such as sandbox_${workspace.current_user.short_name} |
${workspace.host}, ${workspace.file_path} | The workspace URL and deployment paths | Paths to deployed files |
${resources.jobs.<key>.id} | The ID of a job this bundle deploys | A Run Job task that starts another job in the same bundle |
Choose by what should vary. Per-environment values come from ${bundle.target} (if names follow the target name exactly) or from a variable set per target. Per-developer values in a shared dev workspace come from ${workspace.current_user.*}, which is also how development mode builds its user prefix; ${bundle.target} and ${bundle.name} are identical for every developer.
For references between resources in the same bundle, use ${resources...}. It resolves at deploy time to the object that deployment manages, in any workspace and under any development-mode prefix, including on the first deploy to a new workspace. A lookup variable is the wrong tool there: it must find an existing object by name before deployment, so it fails when the job does not exist yet and misses prefixed dev copies. Use lookups only for objects the bundle does not deploy.
Targets and overrides: promoting one codebase to dev, test and prod
A target is a named deployment environment under targets, and settings inside a target override or extend the top-level configuration for that target only. This is how the same codebase is promoted from dev to test to prod while each environment keeps its own settings.
- Default target. Exactly one target may set
default: true; bundle commands run without-tuse it. Neithermode: developmentnor presets make a target the default. - Separate workspaces. When dev, test and prod are different workspaces, set
workspace.hostunder each target. Thendatabricks bundle deploy -t prodgoes to the prod workspace with no--profileoption; credentials still come from the environment, and the CLI can match a configured profile by host. A single top-level host cannot differ per environment. - Resource overrides merge. A target can declare the same resource key as the top level and list only the fields that differ. Lists merge by key: job tasks by
task_keyand job clusters byjob_cluster_key. A prod target entry for jobsales_rollupwith a job cluster keyedsharedholding only a largernode_type_idandnum_workerschanges just those settings; every task that usessharedstill comes from the top-level definition unchanged.
targets:
prod:
resources:
jobs:
sales_rollup:
job_clusters:
- job_cluster_key: shared
new_cluster:
node_type_id: i3.2xlarge
num_workers: 8
Using a new key in the override would add a second cluster that no task uses, and repeating the whole job under the target defeats the point of a shared definition.
Deployment modes, presets and the run_as identity
A target's mode sets a bundle of behaviours suited to personal development or to production.
mode: development | mode: production | |
|---|---|---|
| Resource names | Prefixed with [dev ${workspace.current_user.short_name}], so engineers sharing a dev workspace do not overwrite each other | Unchanged |
| Schedules and triggers | Paused | As configured |
| Jobs and pipelines | Concurrent runs allowed; pipelines run in development mode; deployment lock disabled | Validates that pipelines are not in development mode |
| Git branch | Not checked | If the target sets a Git branch (for example main), deploying from another branch fails validation; --force overrides it |
Presets (such as name_prefix, trigger_pause_status, jobs_max_concurrent_runs, pipelines_development, tags) adjust these defaults, but they apply to every resource in the target. A resource's own setting wins over mode defaults and presets, so to keep one job's hourly schedule running in dev while everything else stays paused and prefixed, set pause_status: UNPAUSED on that job's schedule in the dev target.
run_as. By default, jobs run as the identity that deployed them, so production jobs fail when that person's account is deactivated. Databricks recommends run_as with a service principal (service_principal_name) for production. Set it in the prod target, not at the top level: a top-level run_as applies to every target, so dev jobs would run with production data access, while target-level run_as leaves dev jobs running as whoever deployed them. At the top and target level, run_as accepts a user or a service principal but not a group. A recommended setup separates the deploy identity from the run identity; for a deployer to set run_as to another service principal it needs the CAN_USE permission (Service Principal User role) on that service principal, which is far narrower than admin rights.
Production mode without a service principal. Production mode is designed for service principals. When a user deploys instead, it adds checks so production assets do not belong to one person: root_path, file_path, artifact_path and state_path must not point at a specific user's folder (use a shared path such as /Workspace/Shared/.bundle/prod), and both run_as and permissions must be specified.
The Databricks CLI bundle commands
The Databricks CLI manages bundles through the databricks bundle command group, and almost every command takes -t <target>.
| Command | What it does |
|---|---|
bundle init | Creates a new bundle project from a template |
bundle validate | Loads the configuration for a target, reports syntax errors and warns about properties not in the schema; changes nothing in the workspace |
bundle plan | Shows what a deploy would create, update or delete, without deploying |
bundle deploy | Builds artifacts, uploads files and creates or updates the target's resources; does not run them |
bundle run <key> | Runs a deployed job or pipeline by its resource key (and can pass parameters), so CI needs no workspace IDs |
bundle summary | Prints the bundle, target, workspace paths and each deployed resource with a link |
bundle open <key> | Opens one deployed resource in the browser |
bundle destroy | Deletes everything this bundle deployed to the target (jobs, pipelines, other resources and files) and nothing else; asks for confirmation unless --auto-approve |
bundle generate job|pipeline | Writes YAML (and downloads source files) for an existing workspace job or pipeline; --bind also binds it |
bundle deployment bind / unbind | Links a bundle resource key to an existing workspace object, or removes that link |
Deploy tracks resources by ID, not name. That has two consequences. First, configuration that is not bound creates a new object, so adopting a hand-built job is a two-step move: databricks bundle generate job --existing-job-id <id> to write the YAML, then databricks bundle deployment bind <key> <id> so deploys update the original job and keep its ID and run history. Second, a deploy deletes previously deployed resources that were removed from the configuration. To stop managing a job while keeping it running in the workspace, run bundle deployment unbind for it first, then remove it from the YAML. Unbind is the opposite of destroy: destroy removes what the bundle deployed; unbind leaves the object in place.
Running bundles in CI/CD pipelines
The Databricks CLI is not only for bundles: command groups such as databricks workspace, databricks jobs, databricks pipelines and databricks repos manage workspace objects directly, for example updating a Git folder to the latest commit from CI. A CI/CD pipeline drives the same CLI commands non-interactively, and a typical design ties each stage to a Git event:
- On every pull request:
databricks bundle validateto catch configuration and schema errors cheaply, thenbundle deploy -t testandbundle run -t test <key>so the change is proven in test before a reviewer approves it. - On merge to main:
bundle deploy -t prod. The merge is the only event that reaches prod, which pairs naturally with a production-mode target that checks the Git branch.
Deploy flags for automation:
| Flag | Use |
|---|---|
--auto-approve | Skips interactive approval. Deploys that delete or recreate resources such as pipelines ask for confirmation, which a CI runner cannot give |
--fail-on-active-runs | Fails the deploy if any job or pipeline in the deployment is running |
--force | Force-overrides the Git branch validation (the production-mode branch check) |
--force-lock | Takes the deployment lock even if another deploy appears to hold it |
Bundle deploys hold a deployment lock, stored with the deployment state in the workspace (not in the local .databricks cache), so two deploys to a target cannot collide. A cancelled deploy can leave the lock behind; once you have confirmed nothing else is deploying, one run with --force-lock recovers. --force does not touch the lock, and neither flag changes which resources a deploy removes.
CI identity. Databricks recommends a service principal rather than a user for CI/CD. Workload identity federation is the most secure option: the CI system (for example GitHub Actions) presents its own short-lived OIDC token and receives access as the service principal, so no Databricks secret is stored in the CI system. OAuth machine-to-machine (M2M) also authenticates a service principal but requires storing its client secret. Variable values for CI can come from BUNDLE_VAR_* environment variables or --var, following the precedence above.
Tip. Expect short scenarios about a data engineer's Git folder or bundle with one stated requirement: keep work off main until review, don't rewrite shared history, use one job definition across targets, record no workspace IDs, run prod under a non-human identity, or keep a CI check read-only. Items ask which Git folder action, databricks.yml mapping, variable mechanism, substitution, target setting or CLI command and flag meets it. Harder items combine two facts, such as variable precedence across an environment variable and an overrides file, or deploy deleting removed resources together with unbind, and offer near-twins that differ in one behaviour: merge versus rebase versus reset, include versus sync.include, lookup versus ${resources...} references, --force versus --force-lock, top-level versus target run_as.
- Start new work on a branch created from main in the Git dialog; uncommitted edits carry over when you switch branches.
- Switching to a branch that lacks an asset deletes it; switching back recreates it with a new ID and URL.
- A Git folder has one checked-out branch for everyone, so each developer should clone into their own user folder.
- Commit & Push is one action; pull requests are opened, reviewed and merged in the Git provider, and are updated by pushing to their source branch.
- Merge for shared branches; rebase (which force pushes) for a linear history on your own branch; reset discards commits locally and remotely; Abort undoes a conflicting merge or rebase.
- Top-level include loads more configuration files; sync.include uploads files that .gitignore skips.
- Variable precedence: --var, then BUNDLE_VAR_<name>, then variable-overrides.json, then the target's variables, then the default.
- Use lookup variables for external objects with per-workspace IDs, ${resources.jobs.<key>.id} for jobs in the same bundle, ${bundle.target} per environment and ${workspace.current_user.*} per developer.
- Target overrides merge by task_key and job_cluster_key; set run_as to a service principal in the prod target, never as a group.
- Deploy deletes resources removed from the configuration: generate plus bind to adopt a job, unbind to release one; use --auto-approve in CI and --force-lock only for a stale lock.
Frequently asked questions
What is the difference between Databricks Git folders and Declarative Automation Bundles?
Databricks Git folders (formerly Repos) connect a workspace folder to a Git repository so you can branch, commit, push and pull code from the workspace UI. Declarative Automation Bundles (formerly Databricks Asset Bundles) describe jobs, pipelines and other assets as YAML next to that code and are deployed with the Databricks CLI to dev, test and prod targets. Git folders move code; bundles move code and the job and pipeline definitions together.
Can you create a pull request from a Databricks Git folder?
A Databricks Git folder lets you create a branch, commit and push to it from the Git dialog. The pull request itself is opened, reviewed and merged in the Git provider, such as GitHub or Azure DevOps. To add changes to an open pull request, commit and push more edits to the same source branch.
Should I use merge or rebase in a Databricks Git folder?
Use merge when other people share the branch, because it adds a merge commit and pushes normally. A rebase in a Git folder ends with a force push that rewrites history, so use it only on a branch nobody else has pulled, when you need a linear history. Reset is different again: it hard-resets the branch and force pushes, discarding committed work.
In what order are Databricks bundle variable values resolved?
The Databricks CLI uses the first source that provides a value: the --var option, then a BUNDLE_VAR_<name> environment variable, then the target's .databricks/bundle/<target>/variable-overrides.json file, then the target's variables mapping, and finally the variable's default. Only --var outranks an environment variable set on a CI runner.
When should a bundle use a lookup variable instead of ${resources...}?
A lookup variable resolves an object created outside the bundle, such as a cluster policy or SQL warehouse with the same name but a different ID in each workspace, to its ID at deployment. A ${resources.jobs.<key>.id} reference points at a job the same bundle deploys and works even on the first deploy to a new workspace, where a lookup would fail because the job does not exist yet.
What does development mode change in a Databricks bundle?
A bundle target with mode: development prefixes resource names with [dev <user short name>], pauses schedules and triggers, allows concurrent runs and runs pipelines in development mode, so several engineers can deploy to one dev workspace safely. To keep one job's schedule running, set pause_status: UNPAUSED on that job in the dev target; presets would affect every job.
How do I bring an existing Databricks job under a bundle without creating a copy?
Run databricks bundle generate job --existing-job-id <id> to write the job's YAML into the bundle, then databricks bundle deployment bind <key> <id> to link that configuration to the existing job. Deploys then update the original job and keep its ID and run history; without binding, a deploy would create a second job.
How should a CI pipeline authenticate to deploy a Databricks bundle?
Databricks recommends a service principal rather than a user for CI/CD. Workload identity federation is the most secure option because the CI system exchanges its own short-lived OIDC token for access, so no Databricks secret is stored. OAuth machine-to-machine also uses a service principal but requires storing its client secret.
Source
This lesson covers the "Implementing CI/CD" domain of the official Databricks DEA exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.
Spotted a mistake in this lesson?