Skip to content

Issue with databricks_postgres_project: terraform import forces a destroy/replace because project_id is never reconciled #5911

Description

@vselvarajrvtech

Summary

Importing an existing databricks_postgres_project (via an import {} block or terraform import) always plans a destroy + create replacement of the project, with reason: cannot_update on the immutable project_id field. If the resource has lifecycle { prevent_destroy = true }, the apply hard-fails with Instance cannot be destroyed.

The root cause is that project_id is a required, immutable (RequiresReplace) field, but neither ImportState nor Read populates it, so after import it holds an empty/known value that differs from config. The RequiresReplaceIfKnownChange plan modifier was added specifically to tolerate this post-import case, but it only skips a null prior value, not the empty-string value that actually ends up in state.

There is no way to adopt an existing project without a destructive replace, because databricks_postgres_project (unlike databricks_postgres_branch, _endpoint, _database) has no replace_existing argument.

Terraform and provider versions

  • Terraform: v1.9.8
  • Provider: databricks/databricks v1.123.0
  • databricks-sdk-go: v0.165.0
  • Also reproducible against the same code path in v1.114.0v1.122.0.

Affected resource

databricks_postgres_project

Configuration

Minimal repro (an existing Lakebase project named projects/my-project already exists in the workspace):

resource "databricks_postgres_project" "example" {
  project_id = "my-project"

  spec = {
    display_name               = "my-project"
    pg_version                 = 17
    history_retention_duration = "172800s"
    enable_pg_native_login     = true
    default_endpoint_settings = {
      autoscaling_limit_min_cu = 2
      autoscaling_limit_max_cu = 4
      suspend_timeout_duration = "86400s"
    }
  }

  lifecycle {
    prevent_destroy = true
  }
}

import {
  to = databricks_postgres_project.example
  id = "projects/my-project"
}

Expected behavior

terraform plan adopts the existing project in place (import + no-op, or at most an in-place update of spec). No replacement, no destroy.

Actual behavior

The plan marks the project for replacement:

{
  "type": "planned_change",
  "change": {
    "resource": { "addr": "databricks_postgres_project.example" },
    "action": "replace",
    "reason": "cannot_update",
    "importing": { "id": "projects/my-project" }
  }
}

With prevent_destroy set, the apply then errors:

Error: Instance cannot be destroyed

  on main.tf line 73:
  73: resource "databricks_postgres_project" "example" {

Resource databricks_postgres_project.example has lifecycle.prevent_destroy
set, but the plan calls for this resource to be destroyed.

Without prevent_destroy, Terraform would instead try to delete and recreate the project, which fails against the Lakebase API (the project ID still exists / is in its recovery window), and can result in data loss.

Root cause

project_id is Required and immutable, guarded by RequiresReplaceIf(RequiresReplaceIfKnownChange):

resource_postgres_project.go#L346-L348

attrs["project_id"] = attrs["project_id"].SetRequired()
attrs["project_id"] = attrs["project_id"].(tfschema.StringAttributeBuilder).AddPlanModifier(stringplanmodifier.UseStateForUnknown()).(tfschema.AttributeBuilder)
attrs["project_id"] = attrs["project_id"].(tfschema.StringAttributeBuilder).AddPlanModifier(stringplanmodifier.RequiresReplaceIf(tfschema.RequiresReplaceIfKnownChange, "", "")).(tfschema.AttributeBuilder)

ImportState sets only name, never project_id:

resource_postgres_project.go#L740-L756

func (r *ProjectResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
	parts := strings.Split(req.ID, ",")
	// ...
	name := parts[0]
	resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), name)...)
}

Read then calls GetProject and maps the response, but the SDK model's ProjectId is omitempty and is not returned by the service on a read, and SyncFieldsDuringRead never restores project_id from prior state either:

resource_postgres_project.go#L284-L334 (copies spec, initial_*, status, provider_config, but not project_id).

So after import + Read, project_id in state is an empty, known string (not null). At plan time the modifier compares it to the configured "my-project":

requires_replace_if_known_change.go#L20-L31

func RequiresReplaceIfKnownChange(_ context.Context, req planmodifier.StringRequest, resp *stringplanmodifier.RequiresReplaceIfFuncResponse) {
	if req.StateValue.IsNull() || req.StateValue.IsUnknown() {
		return // tolerated
	}
	if req.PlanValue.IsUnknown() {
		return
	}
	if req.StateValue.ValueString() != req.PlanValue.ValueString() {
		resp.RequiresReplace = true // "" != "my-project" -> replace
	}
}

The modifier's own doc comment says it exists to tolerate exactly this scenario:

This is the right plan modifier for fields that are conceptually immutable but whose prior state may legitimately be null - for example, fields that the API does not echo back on Read, which leaves them null in state after terraform import. A plain RequiresReplace() would treat the post-import null -> configured-value transition as a destructive change.

But the guard only covers a null prior. Because the prior value is an empty string (""), the guard does not skip, and "" -> "my-project" is treated as a known change on an immutable field, forcing the replace.

Why this only bites databricks_postgres_project

The sibling resources share the identical importer (ImportState sets only name) and the identical RequiresReplaceIf(RequiresReplaceIfKnownChange) modifier on their *_id field:

  • databricks_postgres_branch.branch_id
  • databricks_postgres_endpoint.endpoint_id
  • databricks_postgres_database.database_id

But those three expose a replace_existing argument that adopts the existing object in place instead of destroy + create, so the import-replace is masked. databricks_postgres_project has no replace_existing, so there is no way to adopt it non-destructively.

Suggested fixes (any one resolves it)

  1. Populate project_id on import. In ImportState, parse the ID (projects/{project_id}) and set project_id in addition to name, so the post-import value matches config.
  2. Reconcile project_id on Read. Derive project_id from name (or from the read response) so it is a known, correct value after refresh.
  3. Widen the guard. Make RequiresReplaceIfKnownChange also skip when the prior value is an empty string, not just null (matching its documented intent).
  4. Add replace_existing to databricks_postgres_project for parity with branch/endpoint/database, giving users a supported adoption path.

Workarounds

Until the provider is fixed, either:

  • Add ignore_changes so Terraform keeps the adopted value and drops the spurious ForceNew:
  lifecycle {
    prevent_destroy = true
    ignore_changes  = [project_id]
  }
  • Or reference the existing project as a data source instead of managing it:
data "databricks_postgres_project" "example" {
  name = "projects/my-project"
}

Impact

For any team adopting a pre-existing Lakebase project into Terraform, a plain import silently plans a destructive replacement. Without prevent_destroy this can delete a production project and its data; with prevent_destroy it blocks every apply on that workspace until worked around.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions