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.0–v1.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)
- 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.
- Reconcile
project_id on Read. Derive project_id from name (or from the read response) so it is a known, correct value after refresh.
- Widen the guard. Make
RequiresReplaceIfKnownChange also skip when the prior value is an empty string, not just null (matching its documented intent).
- 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.
Summary
Importing an existing
databricks_postgres_project(via animport {}block orterraform import) always plans a destroy + create replacement of the project, withreason: cannot_updateon the immutableproject_idfield. If the resource haslifecycle { prevent_destroy = true }, the apply hard-fails withInstance cannot be destroyed.The root cause is that
project_idis a required, immutable (RequiresReplace) field, but neitherImportStatenorReadpopulates it, so after import it holds an empty/known value that differs from config. TheRequiresReplaceIfKnownChangeplan 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(unlikedatabricks_postgres_branch,_endpoint,_database) has noreplace_existingargument.Terraform and provider versions
v1.9.8databricks/databricks v1.123.0databricks-sdk-go:v0.165.0v1.114.0–v1.122.0.Affected resource
databricks_postgres_projectConfiguration
Minimal repro (an existing Lakebase project named
projects/my-projectalready exists in the workspace):Expected behavior
terraform planadopts the existing project in place (import + no-op, or at most an in-placeupdateofspec). 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_destroyset, the apply then errors: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_idisRequiredand immutable, guarded byRequiresReplaceIf(RequiresReplaceIfKnownChange):resource_postgres_project.go#L346-L348ImportStatesets onlyname, neverproject_id:resource_postgres_project.go#L740-L756Readthen callsGetProjectand maps the response, but the SDK model'sProjectIdisomitemptyand is not returned by the service on a read, andSyncFieldsDuringReadnever restoresproject_idfrom prior state either:resource_postgres_project.go#L284-L334(copiesspec,initial_*,status,provider_config, but notproject_id).So after
import+Read,project_idin 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-L31The modifier's own doc comment says it exists to tolerate exactly this scenario:
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_projectThe sibling resources share the identical importer (
ImportStatesets onlyname) and the identicalRequiresReplaceIf(RequiresReplaceIfKnownChange)modifier on their*_idfield:databricks_postgres_branch.branch_iddatabricks_postgres_endpoint.endpoint_iddatabricks_postgres_database.database_idBut those three expose a
replace_existingargument that adopts the existing object in place instead of destroy + create, so the import-replace is masked.databricks_postgres_projecthas noreplace_existing, so there is no way to adopt it non-destructively.Suggested fixes (any one resolves it)
project_idon import. InImportState, parse the ID (projects/{project_id}) and setproject_idin addition toname, so the post-import value matches config.project_idonRead. Deriveproject_idfromname(or from the read response) so it is a known, correct value after refresh.RequiresReplaceIfKnownChangealso skip when the prior value is an empty string, not just null (matching its documented intent).replace_existingtodatabricks_postgres_projectfor parity with branch/endpoint/database, giving users a supported adoption path.Workarounds
Until the provider is fixed, either:
ignore_changesso Terraform keeps the adopted value and drops the spurious ForceNew:Impact
For any team adopting a pre-existing Lakebase project into Terraform, a plain
importsilently plans a destructive replacement. Withoutprevent_destroythis can delete a production project and its data; withprevent_destroyit blocks every apply on that workspace until worked around.