cancel
Showing results for 
Search instead for 
Did you mean: 
Lakebase Blogs
Discover curated blogs from the community and experts. Learn through practical guides, use cases, and thought pieces designed to help you get more out of Lakebase.
cancel
Showing results for 
Search instead for 
Did you mean: 
AbhilashNagilla
Databricks Employee
Databricks Employee

Moving Beyond Manual Changes: A Guide to Shipping Lakebase Schema with Bundles

Lakebase is a Databricks fully managed PostgreSQL database for online transaction processing (OLTP). Declarative Automation Bundles (formerly Databricks Asset Bundles) are the YAML-and-CLI way to define your Databricks resources in version control and deploy them across environments. Run Lakebase without bundles and each database tends to get built and changes are not tracked, there's no record of which schema is deployed where, and no repeatable way to promote a change from dev to production, so environments drift apart. 

This post outlines a structured way to deploy and promote Lakebase assets and schema changes using DAB bundles. A bundle can declare most of Lakebase directly. The project, its branches and endpoints, roles, databases, and synced tables all deploy as postgres_* resources. What a bundle can't declare is the schema your application owns, its tables and columns, because they're an ordered sequence of changes applied over time, so they ship as versioned migration files run by a job. This post names that split the declarative line, shows the two-layer bundle pattern that spans it, and builds environment promotion and branch-based development on top. Every behavior described here was verified against a live workspace.

When a database is shipped by hand

Most Lakebase databases get built in the UI, with the application's CREATE TABLE statements typed into a SQL editor by hand. Those statements might be saved in a script somewhere, but they usually aren't ordered, versioned, or tied to a way of promoting them.. That holds until the second environment, where the same tables have to exist again and the only reliable record of how they were built is whatever the person who built them remembers. A column added in dev to unblock a feature reaches production only when someone re-runs it there by hand, and the gap often surfaces as a production break. Two environments meant to be identical drift apart one change at a time.

Data engineering teams already solved this for jobs and pipelines with Declarative Automation Bundles, the tool you may still know as Databricks Asset Bundles. Resources live in version-controlled YAML and bundle deploy reconciles them by bringing the workspace to match what the YAML declares. Promotion means switching targets. The bundle provisions the Postgres autoscaling project declaratively, and the tables inside it ship alongside it as versioned migrations.

The declarative line

A bundle for Lakebase splits into two layers, and where that split falls determines everything downstream. Above the line sits every resource the bundle can reconcile on bundle deploy: the project, its branches, endpoints, roles, databases, Unity Catalog registration, and synced tables. These are the postgres_* types, behaving like any other bundle resource. Below it sits the schema your application owns, its tables, columns, indexes, and grants, applied by a job once the infrastructure is in place.

Which side something lands on comes down to ownership: if Databricks derives a table's definition from a source you've already declared elsewhere, like a Unity Catalog table it syncs from, it belongs above; if your application is the authority on what that table looks like, it belongs below.

The declarative line: postgres_* resources and synced tables above it, app-owned tables and versioned migrations below it

AbhilashNagilla_0-1787326848401.png

Figure 1. The declarative line. Everything a bundle can reconcile on deploy sits above it; tables, columns, and data are applied by a job on run below it.

On disk:

bundle/
├── databricks.yml                  # variables + dev/prod targets
├── resources/
│   ├── lakebase.postgres.yml       # above the line: postgres_* infrastructure
│   ├── synced.postgres.yml         # above the line: read-only synced tables
│   └── schema_deploy.job.yml       # below the line: the migration job
└── src/
    ├── deploy_schema.py            # OAuth connect + ordered migration runner
    ├── schema_diff.py              # compare schema across two branches
    └── migrations/
        ├── V1__initial_schema.sql  # orders, order_items
        └── V2__add_order_cols.sql  # ALTER TABLE ADD COLUMN

The infrastructure layer

Bundles handle the infrastructure layer natively, with dedicated resource types for it. Manage Lakebase with Declarative Automation Bundles is the walkthrough, the bundle resources reference has the per-field tables, and Typical Lakebase project setup a fuller bundle with permissions and high availability. Here is the minimum this post builds on.

# resources/lakebase.postgres.yml
resources:
  postgres_projects:
    app:
      project_id: ${var.project_id}
      display_name: ${var.project_display_name}
      pg_version: 17

  postgres_roles:
    app_owner:
      parent: ${resources.postgres_projects.app.id}/branches/production
      role_id: app-owner
      postgres_role: app_owner

  postgres_databases:
    app_db:
      parent: ${resources.postgres_projects.app.id}/branches/production
      database_id: app-db
      postgres_database: appdb
      role: ${resources.postgres_roles.app_owner.id}

Creating a project already gives you a production branch, a primary read-write endpoint, a databricks_postgres database, and an owner role tied to whoever created it. The role and database above sit alongside those rather than replacing them, and declaring them explicitly keeps the bundle portable, since the auto-created role's id derives from the creator's identity and a shared repository can't reference it by name. To adopt that auto-created pair instead of declaring your own, take the role id from list-roles and set replace_existing: true. Declare membership_roles: [DATABRICKS_SUPERUSER] alongside it, because adoption drops that membership otherwise. The ${...} references set deploy order.

Declarative Automation Bundles support for Lakebase is in Beta, so the postgres_* resource types can still change. The databricks postgres CLI group is labeled Beta too, with every subcommand carrying the tag, and the bundle resources carry a minimum CLI version per resource type. The two version independently, so pin your CLI in CI and check the gate for each type you use. This is all Lakebase Autoscaling: bundles still using database_instances keep working, but that resource creates Autoscaling projects now too, so postgres_projects is the type for new work.

pg_version takes a major version number, 17 here, which is the current default. There's no auto or latest option: the field is typed as an integer, so bundle validate rejects pg_version: auto with cannot parse "auto" as an integer. That suits stateful storage anyway: pin the version and upgrade by choice rather than let a database jump a major version on a deploy.

Two fields are worth a decision before you deploy. enable_pg_native_login is off on new projects, so leaving it out keeps Postgres password login disabled, which is what the schema job wants anyway: it authenticates with a short-lived OAuth token instead of a stored password. Set it only if a client needs a static Postgres password.

postgres_catalogs registers the database in Unity Catalog and needs CREATE CATALOG on the metastore, so a bundle author without it sees a 403. The schema job connects to the Postgres endpoint directly, so you can leave the block out and have an admin register the catalog once.

That 403 surfaces at deploy time, since permission checks are server-side. bundle plan sits between validate and deploy and reports which resources a deploy intends to create, which earns it a CI step.

Schema as versioned migrations

For a table carrying live data the path matters as much as the end state: the order columns are added in, and the backfills that run between them. An ordered set of migration files records both.

The pattern that works treats schema the way application teams have for years, as ordered, versioned migration files applied by a runner. In a bundle that runner is a job, running on serverless compute and connecting to the read-write endpoint with a short-lived OAuth token:

# resources/schema_deploy.job.yml
resources:
  jobs:
    schema_deploy:
      name: "Lakebase schema deploy (${var.project_id})"
      parameters:
        - name: branch                   # a job parameter, so it is settable per run
          default: ${var.branch}
      tasks:
        - task_key: apply_migrations
          environment_key: default
          spark_python_task:
            python_file: ../src/deploy_schema.py
            parameters:
              - "--project-id"
              - "${var.project_id}"
              - "--database"
              - "appdb"
              - "--branch"
              - "{{job.parameters.branch}}"
              - "--migrations-dir"
              - "src/migrations"
      environments:
        - environment_key: default
          spec:
            environment_version: "4"
            dependencies:
              - psycopg2-binary
              - databricks-sdk>=0.96.0   # serverless ships an older SDK without w.postgres

The migrations are plain SQL, written to be idempotent so the job is safe to re-run and every environment converges to the same schema:

-- src/migrations/V1__initial_schema.sql
CREATE TABLE IF NOT EXISTS orders (
    order_id     BIGINT       PRIMARY KEY,
    customer_id  BIGINT       NOT NULL,
    status       TEXT         NOT NULL,
    created_at   TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS order_items (
    item_id      BIGINT       PRIMARY KEY,
    order_id     BIGINT       NOT NULL REFERENCES orders (order_id),
    sku          TEXT         NOT NULL,
    quantity     INT          NOT NULL,
    unit_price   NUMERIC(12,2)
);

It works as long as each statement is repeatable, which IF NOT EXISTS gives you for tables and columns. A backfill UPDATE or a RENAME COLUMN needs its own guard, a check that skips the work once it has been applied. Transaction scope is worth knowing too, and it's why setting autocommit on the connection doesn't cost you per-file safety: the runner sends each file to the server as one multi-statement string, and Postgres wraps that string in a single implicit transaction, so a failure on the second of three statements still rolls the whole file back. That boundary is per file, so if V1 commits and V2 fails the schema sits between two versions.

The trimmed runner below re-applies every file on each run and leans on idempotency; it doesn't track which files have already run. As the migration set grows, add a schema_version table the runner checks before applying each file. It records what ran where, lets a statement that can't repeat run exactly once, and keeps the whole thing on the same job and the same targets.

The runner resolves the read-write endpoint, mints a token, and executes each file in filename order.

# src/deploy_schema.py (trimmed)
import argparse, glob, os, pathlib
import psycopg2
from databricks.sdk import WorkspaceClient

def resolve_rw_endpoint(w, project_id, branch):
    """Return the branch's read-write endpoint. A fresh project has a single
    primary, so the fallback below picks it; the READ_WRITE check matters once a
    branch also has read-only replicas. The type is on ep.status.endpoint_type:
    the SDK's Endpoint has no top-level .type field, so checking ep.type raises
    AttributeError. Read it off ep.status.endpoint_type with getattr and a default."""
    parent = f"projects/{project_id}/branches/{branch}"
    rw = None
    for ep in w.postgres.list_endpoints(parent):
        kind = str(getattr(ep.status, "endpoint_type", "") or "") if ep.status else ""
        if "READ_WRITE" in kind:
            rw = ep
            break
        rw = rw or ep
    if rw is None:
        # Usually a misspelled branch name, since branches come up with one.
        raise RuntimeError(f"No endpoint found under {parent}")
    host = rw.status.hosts.host if rw.status and rw.status.hosts else None
    if not host:
        raise RuntimeError(f"Endpoint {rw.name} has no host yet")
    return rw.name, host

def resolve_migrations_dir(rel):
    """`__file__` is undefined under a serverless spark_python_task, so try the
    given path first, then walk up from the working directory."""
    for base in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:
        if glob.glob(os.path.join(base / rel, "V*__*.sql")):
            return str(base / rel)
    raise RuntimeError(f"{rel} not found from {pathlib.Path.cwd()}")

def main():
    p = argparse.ArgumentParser()
    p.add_argument("--project-id", required=True)
    p.add_argument("--database", default="appdb")
    p.add_argument("--branch", default="production")
    p.add_argument("--migrations-dir", default="src/migrations")
    args = p.parse_args()

    w = WorkspaceClient()
    user = w.current_user.me().user_name
    ep_name, host = resolve_rw_endpoint(w, args.project_id, args.branch)
    token = w.postgres.generate_database_credential(endpoint=ep_name).token
    conn = psycopg2.connect(host=host, dbname=args.database, user=user,
                            password=token, sslmode="require")
    conn.autocommit = True

# Fail loudly on an empty match. Without this the glob returns [], the loop
# never runs, and the task reports SUCCESS having applied no DDL at all.
    mig_dir = resolve_migrations_dir(args.migrations_dir)
    files = sorted(glob.glob(os.path.join(mig_dir, "V*__*.sql")))
    print(f"migrations dir: {mig_dir}", flush=True)
    if not files:
        raise RuntimeError(f"No V*__*.sql under {mig_dir}")

    try:
        with conn.cursor() as cur:
            for path in files:
                print(f"applying {os.path.basename(path)} ...", flush=True)
                with open(path) as fh:
                    cur.execute(fh.read())
    finally:
        conn.close()

if __name__ == "__main__":
# Call main() directly, not sys.exit(main()): under a serverless
# spark_python_task, SystemExit reads as a task failure even at code 0.
    main()

Deploying the infrastructure and running the job is two commands:

databricks bundle deploy -t dev --profile <profile>
databricks bundle run schema_deploy -t dev --profile <profile>

Three environment details determine whether this job runs cleanly on serverless, plus one timing behavior to expect:

  • Serverless ships an older databricks-sdk (0.49.0 in testing) with no w.postgres module, so the runner crashes on import unless you pin a newer one. The pin is required, not hygiene. w.postgres first appears in SDK 0.75.0; the >=0.96.0 shown above is a conservative baseline. Pin it in the job's environment dependencies alongside psycopg2-binary.
  • Two runner behaviors under a spark_python_task: __file__ isn't defined, so path resolution relying on it raises NameError; and sys.exit(0) surfaces as a task failure even at exit code zero. Resolve the migrations directory by walking up from the working directory, as the snippet above does, and call main() directly.
  • The first serverless run is cold-start bound and can take a few minutes. If bundle run returns sooner than the run's own duration suggests, confirm the terminal state in the Jobs UI before treating it as a failure.

A CI step invokes those two commands on merge, whether the pipeline lives in GitHub Actions, Azure DevOps, or a Databricks job, with the target selecting the environment.

In CI the connecting identity changes. The runner connects as the calling identity, and whoever created the project got a Postgres owner role automatically. A service principal that didn't create it has no role on that branch, so declare one:

# resources/lakebase.postgres.yml (merges with the block above)
resources:
  postgres_projects:
    app:
      permissions:
        - service_principal_name: ${var.ci_sp_application_id}
          level: CAN_MANAGE
  postgres_roles:
    ci_deployer:
      parent: ${resources.postgres_projects.app.id}/branches/production
      role_id: ci-deployer
      postgres_role: ${var.ci_sp_application_id}
      identity_type: SERVICE_PRINCIPAL
      membership_roles:
        - DATABRICKS_SUPERUSER

identity_type ties the role to a Databricks identity. Leave it off and you get a plain Postgres role no Databricks identity can authenticate as. Under a service principal current_user.me().user_name returns the application id, which is why the role name matches it. The membership matters just as much: the public schema doesn't grant CREATE to everyone, so a role that merely exists can connect and still fail the first CREATE TABLE.

This governs how schema ships, in order and under version control. Whether a statement is safe against a live table is a separate discipline, and standard Postgres practice carries over with two Lakebase adjustments: index-build memory is bounded by the endpoint's compute size, and maintenance_work_mem is settable per session, database, or role but not instance-wide. Scale-to-zero closes idle connections with their session state, so don't serialize two runners with an advisory lock.

AbhilashNagilla_1-1787326848401.png

Figure 2. One deploy provisions the infrastructure above the line; one run applies the schema below it.

The tables that stay above the line

Migrations own the tables your application writes to, and one class of table they shouldn't touch shows why the line follows ownership. A synced table replicates a Unity Catalog Delta table into Postgres on a managed pipeline, and it's a full postgres_* resource:

# resources/synced.postgres.yml
resources:
  postgres_synced_tables:
    customer_segments:
      synced_table_id: ${var.pg_catalog_id}.public.customer_segments_synced
      source_table_full_name: main.analytics.customer_segments
      primary_key_columns: ["customer_id"]
      scheduling_policy: SNAPSHOT
      postgres_database: appdb
      branch: ${resources.postgres_projects.app.id}/branches/production
      create_database_objects_if_missing: true
      new_pipeline_spec:
        storage_catalog: main
        storage_schema: pipeline_storage

That declaration creates a real Postgres table, which looks like a counterexample until you notice you never author its schema. Databricks derives the columns and types from the Delta source, the sync pipeline owns the contents, and you treat it as read-only apart from indexes. Naming the source, primary key, and sync mode therefore covers everything the resource needs, which is why a resource type can exist here and can't for orders.

Keep synced tables out of your migrations too, since anything beyond reads, indexes, and DROP TABLE interferes with the sync.

The synced_table_id above points at the catalog that  postgres_catalogs registers, so this block inherits the same CREATE CATALOG requirement on the metastore. Point it at a standard catalog instead, as the typical project bundle does, and the synced table stops depending on that grant.

That new_pipeline_spec block is what makes the synced table portable across workspaces. Export a synced table's config from the UI and it comes back bound to an existing_pipeline_id, and sync pipelines are scoped to the workspace that created them, so promoting that YAML to another workspace fails with a 404: the pipeline id doesn't exist there. Declaring new_pipeline_spec instead lets each environment create and own its own pipeline, so promotion is a variable swap rather than a broken reference.

A related edge shows up when a higher environment like UAT already has its Lakebase project. postgres_projects always creates; it never adopts a project it didn't create, so bundle deploy -t uat fails with project slug already exists in the workspace, and the role, database, and synced table cascade-fail behind it. There are two clean fixes.

  • The first fix adopts the existing project: point the target's project_id at the existing id, bind the resource once per environment (databricks bundle deployment bind app projects/<their-id> -t uat --auto-approve), and deploy. The bundle reconciles the existing project instead of creating it. Once bound, the project is bundle-managed, so bundle destroy would delete it, which is why prevent_destroy: true stays on prod.
  • The second fix leaves the project out of the bundle entirely, for when a platform team owns project lifecycle. Drop postgres_projects (and the role and database, if they own those) and point the synced table at the existing project with branch: projects/${var.project_id}/branches/production. With no project resource, nothing collides. Either way, pointing at their ids is a variable change, not a rewrite.

Promotion across environments

Both sides of the line now live in the bundle, so promoting all of it is one change of target. The bundle declares its variables once, and each target supplies the values for its environment.

# databricks.yml
bundle:
  name: lakebase-cicd

include:
  - resources/*.yml

variables:
  project_id:
    description: Lakebase project id for this environment.
  project_display_name:
    description: Human-readable project name.
  pg_catalog_id:
    description: Unity Catalog name the Postgres database registers under.
  branch:
    description: Lakebase branch the migrations apply to.
    default: production
ci_sp_application_id:
description: Application id of the CI service principal that deploys and runs the job.

targets:
  dev:
    default: true
    mode: development
    workspace:
      host: https://<workspace-url>
    variables:
      project_id: app-db-dev
      project_display_name: "App DB (dev)"
      pg_catalog_id: app_db_dev_catalog

  prod:
    mode: production
    workspace:
      host: https://<workspace-url>
      # Resolves to the CI service principal that owns prod deploys.
      root_path: /Workspace/Users/<ci-service-principal>/.bundle/${bundle.name}/${bundle.target}
    variables:
      project_id: app-db-prod
      project_display_name: "App DB (prod)"
      pg_catalog_id: app_db_prod_catalog

Each environment is its own Lakebase project, selected by the project_id variable. Deploying and running against dev provisions and populates the dev project; switching to prod does the same against a separate project, from the same bundle code.

A target in mode: production wants an explicit root_path, because the CLI enforces exactly one deployment per target. Its error text suggests a username or principal name in the path; use the CI service principal that owns prod deploys, so it resolves to a single identity. ${workspace.current_user.userName} would resolve to whoever runs the command, giving each of them their own copy of production.

mode: development namespaces most resources with a [dev <user>] prefix so engineers can share a target; postgres_projects isn't one of them. Deploy the dev target above and the job becomes [dev alex] Lakebase schema deploy (app-db-dev) while the project stays plainly app-db-dev, so the second engineer to run bundle deploy -t dev collides on the project id.

Lakebase project ids are global to the workspace, so namespace them per engineer. Pass the value at the command line rather than reaching for ${workspace.current_user.short_name}, which renders first.last as first_last where ids accept only lowercase letters, numbers, and hyphens:

databricks bundle deploy -t dev --var="project_id=app-db-dev-alex"

A --var flag overrides the target's project_id assignment (changing the variable's default does nothing while that assignment stands). Settle it before the second engineer joins, because changing a project id later means destroying the first one.

One bundle deploying to three targets: dev with ephemeral branches, stage as a production-shaped rehearsal, and prod with mode production and prevent_destroy

AbhilashNagilla_2-1787326848401.png

Figure 3. One bundle, one Lakebase project per environment, selected by target.

Branch-based development and rollback

Promotion moves a finished change through the environments; Lakebase branching is how you develop it safely first. Don't conflate a Lakebase branch with the Git branch the bundle lives on: a Lakebase branch is a copy-on-write fork of the database, sharing storage with production until you write to it, so you can try a schema change against real data without touching the original.

The safe way to develop a schema change: fork production, apply the new migration on the fork alone, and compare.

databricks postgres create-branch projects/app-db-dev feature-add-order-cols \
  --json '{"spec": {"source_branch": "projects/app-db-dev/branches/production", "ttl": "3600s"}}' \
  --profile <profile>

The ttl keeps forks from accumulating, and an hour covers applying a migration and applying a migration and comparing it against production.

The fork comes up with its own read-write endpoint named primary on its own host, so you can connect to it straight away. Point resolve_rw_endpoint at the branch and it finds it.

postgres_branches and postgres_endpoints exist as resources too, and a long-lived shared dev branch belongs there. These per-change forks stay out of bundle state deliberately.

The new migration adds two columns to orders:

-- src/migrations/V2__add_order_cols.sql
ALTER TABLE orders ADD COLUMN IF NOT EXISTS fulfilled_at TIMESTAMPTZ;
ALTER TABLE orders ADD COLUMN IF NOT EXISTS channel      TEXT;

Applying V2 to the fork takes one flag. Bundle variables resolve at deploy time, so bundle run --var="branch=..." would re-run the already-deployed job against production. Declaring the branch as a job parameter is what makes it settable per run:

databricks bundle run schema_deploy -t dev \
  --params branch=feature-add-order-cols --profile <profile>

Diffing the fork against production shows the delta. Read information_schema on both branches and set-difference the columns, reusing resolve_rw_endpoint from the runner above:

# src/schema_diff.py (trimmed)
def columns_for_branch(w, project_id, branch, database, user):
    ep_name, host = resolve_rw_endpoint(w, project_id, branch)
    token = w.postgres.generate_database_credential(endpoint=ep_name).token
    conn = psycopg2.connect(host=host, dbname=database, user=user,
                            password=token, sslmode="require")
    with conn.cursor() as cur:
        cur.execute("SELECT table_name || '.' || column_name "
                    "FROM information_schema.columns "
                    "WHERE table_schema='public' ORDER BY 1;")
        cols = {r[0] for r in cur.fetchall()}
    conn.close()
    return cols

left = columns_for_branch(w, project_id, args.left, args.database, user)
right = columns_for_branch(w, project_id, args.right, args.database, user)

print(f"=== SCHEMA DIFF: {args.left}  vs  {args.right} ===")
print(f"columns ONLY in {args.left} (the delta to promote):")
for c in sorted(left - right):
    print("   +", c)
print(f"columns only in {args.right}:", sorted(right - left) or "(none)")
print(f"\n{args.left} column count: {len(left)} | {args.right} column count: {len(right)}")

which prints:

=== SCHEMA DIFF: feature-add-order-cols  vs  production ===
columns ONLY in feature-add-order-cols (the delta to promote):
   + orders.channel
   + orders.fulfilled_at
columns only in production: (none)

feature-add-order-cols column count: 11 | production column count: 9

Stitch that diff into a pull request check, so the schema delta gets reviewed the same way the migration file does.

When the change is ready, promote the migration rather than the branch: the same SQL file moves to main and then through the bundle targets. The branches documentation settles why, noting branch reset only works parent to child and that moving changes the other way is a job for migration tooling. Rollback works the same way: until the migration is promoted, production never saw it, and the fork can be discarded. That's also where this parts ways with branch-per-pull-request, which would put database state on a second promotion path alongside the one your other resources already use.

If your pipeline tears environments down and rebuilds them, bundle destroy soft-deletes the project and reserves the slug for seven days, so it can be recovered with its data intact. Redeploying the same project_id inside that window then fails with project slug already exists in the workspace while list-projects shows nothing; list-projects --show-deleted reports the delete_time and purge_time holding the name, and undelete-project recovers it.

An ephemeral CI environment that recreates the same project every run hits this. Set purge_on_delete in the bundle so bundle destroy hard-deletes.

# Scoped to the ephemeral target only.
targets:
  dev:
    resources:
      postgres_projects:
        app:
          purge_on_delete: true

Deploy, destroy, and redeploy the same project_id back to back with that set and the second deploy succeeds; the project never appears in --show-deleted, so the slug was released rather than reserved. For a project the bundle doesn't own, the CLI flag does the same:

databricks postgres delete-project projects/<project-id> --purge --profile <profile>

Set purge_on_delete on ephemeral targets only. On anything holding real data, keep the seven-day window and add a lifecycle block with prevent_destroy: true, which fails the destroy with an error.

Where each pattern belongs

What you're shipping tells you which side of the line it belongs on:

What you're shipping

Side of the line

How it ships

Project, branch, endpoint, role, database

Above

postgres_* resource, reconciled by `bundle deploy`

A read-only copy of a Unity Catalog table

Above

postgres_synced_tables; never also write it as a migration

App-owned tables, columns, indexes, grants

Below

Versioned migration file, applied by bundle run

The same schema in a new environment

Both

A new target with its own project_id; deploy then run

A risky change tested against real data

Below, on a fork

Lakebase branch, then promote the migration, discard the branch

Capturing UI-built objects into the bundle

Both

Author the postgres_* YAML by hand, then bring existing tables under migration files

For that last row, author the postgres_* blocks by hand from what get-project reports, since bundle generate currently covers jobs, pipelines, apps, dashboards, alerts, and Genie spaces. Then fold the existing tables into a V1 migration so the repository becomes the source of truth.

Conclusion

Adopt the split in the order that matches your risk. Bring the postgres_* resources under the bundle first, which is reversible and touches no data. Fold existing tables into a V1 migration once the repository is the only place new DDL gets written. Add the branch-and-diff step last, when someone needs to test against production-shaped data. Dropping this into an existing bundle means one more file under resources/ and a src/ directory alongside it.

Everything here was validated on CLI v1.10.0. Pin your version and check the changelog as you adopt this.