Terraform Interview Questions and Answers Part 7 - Refactoring, Import and Lifecycle

Terraform Interview Q&A · Part 7 of 10

In this post we are going to cover refactoring, import and lifecycle in Terraform. These are 7 real Terraform interview questions for data engineers, asked the way they come up on a real project, and the last one is a scenario: you inherit a messy two-thousand-line configuration with hand-made resources mixed in, and you have to clean it up safely.

Real platforms are never built in one clean pass. Someone made a storage account in the portal, someone else wants to split files into modules, and another team wants to take over a resource. Interviewers ask about import, moved, removed and the lifecycle settings because these are the tools that let you change code without deleting real data.

Part 7 of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT.

The short answer

Say this first

Bring hand-made resources in with import blocks, refactor addresses with moved blocks, and hand resources over with removed blocks and destroy = false. Use ignore_changes for attributes someone else owns, create_before_destroy to avoid a gap during replacement, and prevent_destroy plus Azure locks for data you can't lose. Every refactor plan must show 0 to destroy.

Let's set up the project

We use a small Terraform 1.17 project. Here we use local resources, local_file and terraform_data, as stand-ins for storage accounts and a database, so every plan and apply is real and nothing is created in Azure. The Azure version of the code in the azure folder is checked with terraform validate. Here are the starting files.

main.tf
terraform {
  required_version = ">= 1.17"
  required_providers {
    local = { source = "hashicorp/local", version = "~> 2.9" }
  }
}
lake.tf
resource "local_file" "raw" {
  filename = "${path.module}/lake/raw/.keep"
  content  = "raw zone of the sales lake"
}

resource "local_file" "curated" {
  filename = "${path.module}/lake/curated/.keep"
  content  = "curated zone of the sales lake"
}
reporting.tf
resource "local_file" "reporting_share" {
  filename = "${path.module}/reporting/share.txt"
  content  = "Power BI export share, owned by the reporting team"
}
tags.tf
variable "tags" {
  type = map(string)
  default = {
    env   = "dev"
    owner = "sales-data"
  }
}

# stand-in for the storage account and its tags
resource "terraform_data" "sales_account" {
  input = var.tags
}
gateway.tf
variable "gateway_sku" {
  type    = string
  default = "standard"
}

# default: destroy the old one, then create the new one
resource "terraform_data" "gateway_default" {
  triggers_replace = var.gateway_sku
}

resource "terraform_data" "gateway_cbd" {
  triggers_replace = var.gateway_sku

  lifecycle {
    create_before_destroy = true
  }
}
prod.tf
resource "terraform_data" "prod_db" {
  input = "sqldb-sales-prod"

  lifecycle {
    prevent_destroy = true
  }
}
azure/main.tf
terraform {
  required_version = ">= 1.17"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 5.11" }
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "sales" {
  name     = "rg-sales-data-dev"
  location = "eastus2"
}

variable "sql_server_id" {
  type = string
}
azure/import.tf
locals {
  sub = "/subscriptions/00000000-0000-0000-0000-000000000000"
  rg  = "${local.sub}/resourceGroups/rg-sales-data-dev"
  st  = "providers/Microsoft.Storage/storageAccounts"
}

# the account made by hand in the portal last year
import {
  to = azurerm_storage_account.landing
  id = "${local.rg}/${local.st}/stsaleslanding01"
}
azure/storage.tf
resource "azurerm_storage_account" "landing" {
  name                     = "stsaleslanding01"
  resource_group_name      = azurerm_resource_group.sales.name
  location                 = azurerm_resource_group.sales.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  is_hns_enabled           = true
  min_tls_version          = "TLS1_2"
  tags                     = { env = "dev", owner = "sales-data" }

  lifecycle {
    prevent_destroy = true
    ignore_changes  = [tags["CreatedBy"]]
  }
}
azure/protect.tf
resource "azurerm_mssql_database" "sales" {
  name      = "sqldb-sales-prod"
  server_id = var.sql_server_id

  lifecycle {
    prevent_destroy = true
  }
}

resource "azurerm_management_lock" "sales_db" {
  name       = "lock-sqldb-sales-prod"
  scope      = azurerm_mssql_database.sales.id
  lock_level = "CanNotDelete"
  notes      = "Production sales database: do not delete"
}

Question 1: A storage account was created by hand last year and now must be managed by Terraform. How do you import it?

With an import block. It has two parts: to, the address in our code, and id, how the provider finds the real object.

For azurerm, the id is the full Azure resource ID: subscription, resource group, provider and account name. In azure/import.tf it is built from locals. Let's run terraform validate on the Azure folder.

Terminal
$ terraform -chdir=azure validate
Success! The configuration is valid.

Validate accepts it. Now let's do it for real. A terraform_data resource stands in for the account, and there is no resource block for it yet. So we add the import block and let terraform plan write the code with -generate-config-out.

import.tf
import {
  to = terraform_data.landing
  id = "stsaleslanding01"
}
Terminal
$ terraform plan -refresh=false -generate-config-out=generated.tf > plan.txt
$ grep -E 'Preparing import|will be imported|config will be generated|Plan:' plan.txt
terraform_data.landing: Preparing import... [id=stsaleslanding01]
  # terraform_data.landing will be imported
  # (config will be generated)
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
$ cat generated.tf
# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "stsaleslanding01"
resource "terraform_data" "landing" {
  input            = null
  triggers_replace = null
}

The plan says terraform_data.landing will be imported, (config will be generated), and Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

Always review generated code. It writes every argument, even nulls, and the docs still mark it experimental. So we keep a clean version in our own file, landing.tf, delete generated.tf, and apply.

landing.tf
# made by hand in the portal last year, now managed here
resource "terraform_data" "landing" {}

Delete the file generated.tf.

Terminal
$ terraform apply -auto-approve -refresh=false
terraform_data.landing: Preparing import... [id=stsaleslanding01]
terraform_data.landing: Refreshing state... [id=stsaleslanding01]

Terraform will perform the following actions:

  # terraform_data.landing will be imported
    resource "terraform_data" "landing" {
        id = "stsaleslanding01"
    }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
terraform_data.landing: Importing... [id=stsaleslanding01]
terraform_data.landing: Import complete [id=stsaleslanding01]

Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.

We get Import complete and Resources: 1 imported, 0 added, 0 changed, 0 destroyed.

My rule

Read the import plan. If it also shows a change or a replacement, the code doesn't match the real account, so fix the code first.

For many resources, import blocks take for_each. And since Terraform 1.14, terraform query can list existing storage accounts and generate the import blocks for you.

Question 2: You split one big file into modules and the plan wants to recreate everything. How do moved blocks fix that?

Terraform tracks every object by its address. Move the code, and the address changes, so Terraform thinks it is a new object.

Let's split lake.tf into a module. local_file.raw becomes module.lake.local_file.zone["raw"]. Same file, new address. We run terraform get and terraform plan.

lake.tf
module "lake" {
  source = "./modules/lake"
  zones  = ["raw", "curated"]
}
modules/lake/main.tf
variable "zones" {
  type        = set(string)
  description = "Lake zones, one folder each."
}

resource "local_file" "zone" {
  for_each = var.zones

  filename = "${path.root}/lake/${each.key}/.keep"
  content  = "${each.key} zone of the sales lake"
}
Terminal
$ terraform get
- lake in modules/lake
$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  - destroy

Terraform will perform the following actions:

  # local_file.curated will be destroyed
  # (because local_file.curated is not in configuration)
  - resource "local_file" "curated" {
      - content              = "curated zone of the sales lake" -> null
      - content_base64sha256 = "Bm1UmO8zgVzWujj4YR91theBOOREVQp+p2OddkQgGOY=" -> null
      - content_base64sha512 =
        "9GC3T4LY6IMX9KWlFBR9h0kXDaZFCi4zxqSWtAy/hY2nPk8u8wt8ZrsfBbRO6jnyzZMRoepqd94ZcozQk5XuYA==" ->
          null
      - content_md5          = "1625a161c80be31786db2c237b2298b8" -> null
      - content_sha1         = "218cc1c6a03b0297307473103d893d6300e0acdf" -> null
      - content_sha256       = "066d5498ef33815cd6ba38f8611f75b6178138e444550a7ea7639d76442018e6" ->
        null
      - content_sha512       =
        "f460b74f82d8e88317f4a5a514147d8749170da6450a2e33c6a496b40cbf858da73e4f2ef30b7c66bb1f05b44eea39f
          2cd9311a1ea6a77de19728cd09395ee60" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./lake/curated/.keep" -> null
      - id                   = "218cc1c6a03b0297307473103d893d6300e0acdf" -> null
    }

  # local_file.raw will be destroyed
  # (because local_file.raw is not in configuration)
  - resource "local_file" "raw" {
      - content              = "raw zone of the sales lake" -> null
      - content_base64sha256 = "GJFiXd0g1UJ/SVM8Ph/ZzQkFAyl3TyQcudlSJXJz05U=" -> null
      - content_base64sha512 =
        "u1albTMoGn5yjgkbw0j0/g2XCnQfq7izQiNiFqc6fTp+QDcDHnqDq8I8UEK0P/iJUx4BIyaxJAUX9l/7x6MljA==" ->
          null
      - content_md5          = "ce75d0ddeb64f9df2ea05e30339a06c8" -> null
      - content_sha1         = "d00fbaa40b57ae580cc1b69283185af58b0cd547" -> null
      - content_sha256       = "1891625ddd20d5427f49533c3e1fd9cd09050329774f241cb9d952257273d395" ->
        null
      - content_sha512       =
        "bb56a56d33281a7e728e091bc348f4fe0d970a741fabb8b342236216a73a7d3a7e4037031e7a83abc23c5042b43ff88
          9531e012326b1240517f65ffbc7a3258c" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./lake/raw/.keep" -> null
      - id                   = "d00fbaa40b57ae580cc1b69283185af58b0cd547" -> null
    }

  # module.lake.local_file.zone["curated"] will be created
  + resource "local_file" "zone" {
      + content              = "curated zone of the sales lake"
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "./lake/curated/.keep"
      + id                   = (known after apply)
    }

  # module.lake.local_file.zone["raw"] will be created
  + resource "local_file" "zone" {
      + content              = "raw zone of the sales lake"
      + content_base64sha256 = (known after apply)
      + content_base64sha512 = (known after apply)
      + content_md5          = (known after apply)
      + content_sha1         = (known after apply)
      + content_sha256       = (known after apply)
      + content_sha512       = (known after apply)
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "./lake/raw/.keep"
      + id                   = (known after apply)
    }

Plan: 2 to add, 0 to change, 2 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan says Plan: 2 to add, 0 to change, 2 to destroy., because local_file.raw is not in configuration. For a real storage account, that plan deletes the lake and creates an empty one.

A moved block says it is the same object: from the old address, to the new one. Let's add two moved blocks, then plan, apply and list the state.

moved.tf
moved {
  from = local_file.raw
  to   = module.lake.local_file.zone["raw"]
}

moved {
  from = local_file.curated
  to   = module.lake.local_file.zone["curated"]
}
Terminal
$ terraform plan -refresh=false
Terraform will perform the following actions:

  # local_file.curated has moved to module.lake.local_file.zone["curated"]
    resource "local_file" "zone" {
        id                   = "218cc1c6a03b0297307473103d893d6300e0acdf"
        # (10 unchanged attributes hidden)
    }

  # local_file.raw has moved to module.lake.local_file.zone["raw"]
    resource "local_file" "zone" {
        id                   = "d00fbaa40b57ae580cc1b69283185af58b0cd547"
        # (10 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.
$ terraform apply -auto-approve -refresh=false
Terraform will perform the following actions:

  # local_file.curated has moved to module.lake.local_file.zone["curated"]
    resource "local_file" "zone" {
        id                   = "218cc1c6a03b0297307473103d893d6300e0acdf"
        # (10 unchanged attributes hidden)
    }

  # local_file.raw has moved to module.lake.local_file.zone["raw"]
    resource "local_file" "zone" {
        id                   = "d00fbaa40b57ae580cc1b69283185af58b0cd547"
        # (10 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform state list
local_file.reporting_share
terraform_data.gateway_cbd
terraform_data.gateway_default
terraform_data.landing
terraform_data.prod_db
terraform_data.sales_account
module.lake.local_file.zone["curated"]
module.lake.local_file.zone["raw"]

Now local_file.raw has moved to module.lake.local_file.zone["raw"], and the plan is 0 to add, 0 to change, 0 to destroy. After the apply, state list shows the new module addresses.

Azure works the same way: from = azurerm_storage_account.lake and to = module.lake.azurerm_storage_account.this.

Ship the moved blocks with the refactor, and keep them. Removing one is a breaking change for any state that hasn't applied it yet.

Why not state mv

terraform state mv works too, but it edits the state with no plan to review. Moved blocks are code: reviewed, planned, and repeated in every environment.

Question 3: A team wants to stop managing a resource with Terraform but keep it running. How do removed blocks work?

Delete the resource block and add a removed block with from, the resource address, and a lifecycle block with destroy = false. Terraform forgets the object and leaves it running.

Here the reporting share moves to the reporting team. We delete reporting.tf, add removed.tf, and run terraform plan.

removed.tf
removed {
  from = local_file.reporting_share

  lifecycle {
    destroy = false
  }
}

Delete the file reporting.tf.

Terminal
$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:

Terraform will perform the following actions:

 # local_file.reporting_share will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)
 . resource "local_file" "reporting_share" {
        id                   = "d05330b24a057e0c4e67cd82e0cb4795249b4439"
        # (10 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

Warning: Some objects will no longer be managed by Terraform

If you apply this plan, Terraform will discard its tracking information for
the following objects, but it will not delete them:
 - local_file.reporting_share

After applying this plan, Terraform will no longer manage these objects. You
will need to import them into Terraform to manage them again.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan shows a different symbol, a dot: local_file.reporting_share will no longer be managed by Terraform, but will not be destroyed. We get 0 to destroy, and a warning that lists exactly what Terraform will forget.

Now we apply, list the state, and check the file.

Terminal
$ terraform apply -auto-approve -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:

Terraform will perform the following actions:

 # local_file.reporting_share will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)
 . resource "local_file" "reporting_share" {
        id                   = "d05330b24a057e0c4e67cd82e0cb4795249b4439"
        # (10 unchanged attributes hidden)
    }

Plan: 0 to add, 0 to change, 0 to destroy.

Warning: Some objects will no longer be managed by Terraform

If you apply this plan, Terraform will discard its tracking information for
the following objects, but it will not delete them:
 - local_file.reporting_share

After applying this plan, Terraform will no longer manage these objects. You
will need to import them into Terraform to manage them again.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform state list
terraform_data.gateway_cbd
terraform_data.gateway_default
terraform_data.landing
terraform_data.prod_db
terraform_data.sales_account
module.lake.local_file.zone["curated"]
module.lake.local_file.zone["raw"]
$ cat reporting/share.txt
Power BI export share, owned by the reporting team

state list no longer has local_file.reporting_share, and the file is still there: Power BI export share, owned by the reporting team. In Azure, the resource keeps running, and the other team imports it into their own code.

Why not terraform state rm? It works, but it edits the state with no plan to review and nothing in Git. The removed block goes through the normal pull request and plan.

Versions

Removed blocks need Terraform 1.7 or later. Since Terraform 1.16, destroy = false can also sit in the resource's own lifecycle block, as we see in question 6.

Question 4: A tag added by Azure policy keeps showing up as a change in every plan. How does lifecycle ignore_changes help?

ignore_changes tells Terraform to leave one attribute alone when it plans updates, so the plan stops fighting the policy.

Azure Policy adds a CreatedBy tag that isn't in our code, so every plan wants to remove it. There is no Azure Policy here, so a small script, policy.py, plays it: it writes the tag into the state, the way a refresh would. Then we run terraform plan.

policy.py
# Plays Azure Policy: adds a CreatedBy tag to the stored object,
# the way a refresh records a tag that was added outside Terraform.
import json

state = json.load(open("terraform.tfstate"))
for r in state["resources"]:
    if r["name"] == "sales_account":
        attrs = r["instances"][0]["attributes"]
        for key in ("input", "output"):
            attrs[key]["value"]["CreatedBy"] = "azure-policy"
state["serial"] += 1
json.dump(state, open("terraform.tfstate", "w"), indent=2)
Terminal
$ python3 policy.py
$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # terraform_data.sales_account will be updated in-place
  ~ resource "terraform_data" "sales_account" {
        id     = "<uuid>"
      ~ input  = {
          - CreatedBy = "azure-policy"
            # (2 unchanged attributes hidden)
        }
      ~ output = {
          - CreatedBy = "azure-policy"
          - env       = "dev"
          - owner     = "sales-data"
        } -> (known after apply)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan wants to update terraform_data.sales_account in place and remove CreatedBy = "azure-policy": 1 to change. That shows up in every plan, forever, and people stop reading plans that are never clean.

Let's add lifecycle { ignore_changes = [input["CreatedBy"]] } and plan again. Here it is input["CreatedBy"]; on a real storage account it is tags["CreatedBy"], as in azure/storage.tf.

tags.tf
variable "tags" {
  type = map(string)
  default = {
    env   = "dev"
    owner = "sales-data"
  }
}

# stand-in for the storage account and its tags
resource "terraform_data" "sales_account" {
  input = var.tags

  lifecycle {
    ignore_changes = [input["CreatedBy"]]
  }
}
Terminal
$ terraform plan -refresh=false
No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

No changes. Your infrastructure matches the configuration.

Be precise. Ignore only the key the policy owns, not all tags, and avoid ignore_changes = all, or your own changes are ignored too. It only affects updates: on create, Terraform still sends the code's value.

Tip

If the value is known, I often put the tag in our own tag map instead, so the code and Azure agree.

Question 5: Replacing a resource causes downtime because the old one is deleted first. What does create_before_destroy do?

When a change forces replacement, Terraform by default destroys the old object first, then creates the new one. In between, nothing is there. create_before_destroy flips the order: the new one first, the old one after.

We have two stand-in gateways: gateway_default, and gateway_cbd with create_before_destroy = true. Let's change the SKU from standard to premium, which forces replacement, and run terraform plan.

gateway.tf
variable "gateway_sku" {
  type    = string
  default = "premium"
}

# default: destroy the old one, then create the new one
resource "terraform_data" "gateway_default" {
  triggers_replace = var.gateway_sku
}

resource "terraform_data" "gateway_cbd" {
  triggers_replace = var.gateway_sku

  lifecycle {
    create_before_destroy = true
  }
}
Terminal
$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement
+/- create replacement and then destroy

Terraform will perform the following actions:

  # terraform_data.gateway_cbd must be replaced
+/- resource "terraform_data" "gateway_cbd" {
      ~ id               = "<uuid>" -> (known after apply)
      ~ triggers_replace = "standard" -> "premium"
    }

  # terraform_data.gateway_default must be replaced
-/+ resource "terraform_data" "gateway_default" {
      ~ id               = "<uuid>" -> (known after apply)
      ~ triggers_replace = "standard" -> "premium"
    }

Plan: 2 to add, 0 to change, 2 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan shows both symbols: -/+ destroy and then create replacement for gateway_default, and +/- create replacement and then destroy for gateway_cbd.

Now we apply and pull out the lines for each gateway.

Terminal
$ terraform apply -auto-approve -refresh=false > apply.txt
$ grep "gateway_default: " apply.txt
terraform_data.gateway_default: Destroying... [id=<uuid>]
terraform_data.gateway_default: Destruction complete after <time>
terraform_data.gateway_default: Creating...
terraform_data.gateway_default: Creation complete after <time> [id=<uuid>]
$ grep "gateway_cbd: " apply.txt
terraform_data.gateway_cbd: Creating...
terraform_data.gateway_cbd: Creation complete after <time> [id=<uuid>]
terraform_data.gateway_cbd: Destruction complete after <time>

The apply proves the order. gateway_default is destroyed, then created. gateway_cbd is created first, and only then is the old one destroyed.

The catch: for a moment, both exist. Many Azure names must be unique, and storage account names are global, so the new one can't reuse the old name. Give replaceable resources a name suffix that changes with the replacement.

It spreads

If this resource depends on another, Terraform turns on create_before_destroy for that one too.

Question 6: Your manager asks how to make sure a production database is never deleted by Terraform. What do you configure?

Layers. In Terraform, lifecycle { prevent_destroy = true }. Any plan that would destroy it, a destroy or a replacement, fails.

Let's run terraform destroy on the whole project.

Terminal (expected error)
$ terraform destroy -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform planned the following actions, but then encountered a problem:

  # terraform_data.gateway_cbd will be destroyed
  - resource "terraform_data" "gateway_cbd" {
      - id               = "<uuid>" -> null
      - triggers_replace = "premium" -> null
    }

  # terraform_data.gateway_default will be destroyed
  - resource "terraform_data" "gateway_default" {
      - id               = "<uuid>" -> null
      - triggers_replace = "premium" -> null
    }

  # terraform_data.landing will be destroyed
  - resource "terraform_data" "landing" {
      - id = "stsaleslanding01" -> null
    }

  # terraform_data.prod_db will be destroyed
  - resource "terraform_data" "prod_db" {
      - id     = "<uuid>" -> null
      - input  = "sqldb-sales-prod" -> null
      - output = "sqldb-sales-prod" -> null
    }

  # terraform_data.sales_account will be destroyed
  - resource "terraform_data" "sales_account" {
      - id     = "<uuid>" -> null
      - input  = {
          - CreatedBy = "azure-policy"
          - env       = "dev"
          - owner     = "sales-data"
        } -> null
      - output = {
          - CreatedBy = "azure-policy"
          - env       = "dev"
          - owner     = "sales-data"
        } -> null
    }

  # module.lake.local_file.zone["curated"] will be destroyed
  - resource "local_file" "zone" {
      - content              = "curated zone of the sales lake" -> null
      - content_base64sha256 = "Bm1UmO8zgVzWujj4YR91theBOOREVQp+p2OddkQgGOY=" -> null
      - content_base64sha512 =
        "9GC3T4LY6IMX9KWlFBR9h0kXDaZFCi4zxqSWtAy/hY2nPk8u8wt8ZrsfBbRO6jnyzZMRoepqd94ZcozQk5XuYA==" ->
          null
      - content_md5          = "1625a161c80be31786db2c237b2298b8" -> null
      - content_sha1         = "218cc1c6a03b0297307473103d893d6300e0acdf" -> null
      - content_sha256       = "066d5498ef33815cd6ba38f8611f75b6178138e444550a7ea7639d76442018e6" ->
        null
      - content_sha512       =
        "f460b74f82d8e88317f4a5a514147d8749170da6450a2e33c6a496b40cbf858da73e4f2ef30b7c66bb1f05b44eea39f
          2cd9311a1ea6a77de19728cd09395ee60" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./lake/curated/.keep" -> null
      - id                   = "218cc1c6a03b0297307473103d893d6300e0acdf" -> null
    }

  # module.lake.local_file.zone["raw"] will be destroyed
  - resource "local_file" "zone" {
      - content              = "raw zone of the sales lake" -> null
      - content_base64sha256 = "GJFiXd0g1UJ/SVM8Ph/ZzQkFAyl3TyQcudlSJXJz05U=" -> null
      - content_base64sha512 =
        "u1albTMoGn5yjgkbw0j0/g2XCnQfq7izQiNiFqc6fTp+QDcDHnqDq8I8UEK0P/iJUx4BIyaxJAUX9l/7x6MljA==" ->
          null
      - content_md5          = "ce75d0ddeb64f9df2ea05e30339a06c8" -> null
      - content_sha1         = "d00fbaa40b57ae580cc1b69283185af58b0cd547" -> null
      - content_sha256       = "1891625ddd20d5427f49533c3e1fd9cd09050329774f241cb9d952257273d395" ->
        null
      - content_sha512       =
        "bb56a56d33281a7e728e091bc348f4fe0d970a741fabb8b342236216a73a7d3a7e4037031e7a83abc23c5042b43ff88
          9531e012326b1240517f65ffbc7a3258c" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./lake/raw/.keep" -> null
      - id                   = "d00fbaa40b57ae580cc1b69283185af58b0cd547" -> null
    }

Plan: 0 to add, 0 to change, 7 to destroy.

Error: Instance cannot be destroyed

  on prod.tf line 1:
   1: resource "terraform_data" "prod_db" {

Resource terraform_data.prod_db has lifecycle.prevent_destroy set, but the
plan calls for this resource to be destroyed. To avoid this error and
continue with the plan, either disable lifecycle.prevent_destroy or reduce
the scope of the plan using the -target option.

It plans 7 to destroy, then stops: Error: Instance cannot be destroyed, because terraform_data.prod_db has lifecycle.prevent_destroy set. Nothing is deleted.

New in Terraform 1.16 is lifecycle { destroy = false }. Let's switch the database to that and run terraform plan -destroy.

prod.tf
resource "terraform_data" "prod_db" {
  input = "sqldb-sales-prod"

  lifecycle {
    destroy = false
  }
}
Terminal
$ terraform plan -destroy -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # terraform_data.gateway_cbd will be destroyed
  - resource "terraform_data" "gateway_cbd" {
      - id               = "<uuid>" -> null
      - triggers_replace = "premium" -> null
    }

  # terraform_data.gateway_default will be destroyed
  - resource "terraform_data" "gateway_default" {
      - id               = "<uuid>" -> null
      - triggers_replace = "premium" -> null
    }

  # terraform_data.landing will be destroyed
  - resource "terraform_data" "landing" {
      - id = "stsaleslanding01" -> null
    }

 # terraform_data.prod_db will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)
 . resource "terraform_data" "prod_db" {
        id     = "<uuid>"
        # (2 unchanged attributes hidden)
    }

  # terraform_data.sales_account will be destroyed
  - resource "terraform_data" "sales_account" {
      - id     = "<uuid>" -> null
      - input  = {
          - CreatedBy = "azure-policy"
          - env       = "dev"
          - owner     = "sales-data"
        } -> null
      - output = {
          - CreatedBy = "azure-policy"
          - env       = "dev"
          - owner     = "sales-data"
        } -> null
    }

  # module.lake.local_file.zone["curated"] will be destroyed
  - resource "local_file" "zone" {
      - content              = "curated zone of the sales lake" -> null
      - content_base64sha256 = "Bm1UmO8zgVzWujj4YR91theBOOREVQp+p2OddkQgGOY=" -> null
      - content_base64sha512 =
        "9GC3T4LY6IMX9KWlFBR9h0kXDaZFCi4zxqSWtAy/hY2nPk8u8wt8ZrsfBbRO6jnyzZMRoepqd94ZcozQk5XuYA==" ->
          null
      - content_md5          = "1625a161c80be31786db2c237b2298b8" -> null
      - content_sha1         = "218cc1c6a03b0297307473103d893d6300e0acdf" -> null
      - content_sha256       = "066d5498ef33815cd6ba38f8611f75b6178138e444550a7ea7639d76442018e6" ->
        null
      - content_sha512       =
        "f460b74f82d8e88317f4a5a514147d8749170da6450a2e33c6a496b40cbf858da73e4f2ef30b7c66bb1f05b44eea39f
          2cd9311a1ea6a77de19728cd09395ee60" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./lake/curated/.keep" -> null
      - id                   = "218cc1c6a03b0297307473103d893d6300e0acdf" -> null
    }

  # module.lake.local_file.zone["raw"] will be destroyed
  - resource "local_file" "zone" {
      - content              = "raw zone of the sales lake" -> null
      - content_base64sha256 = "GJFiXd0g1UJ/SVM8Ph/ZzQkFAyl3TyQcudlSJXJz05U=" -> null
      - content_base64sha512 =
        "u1albTMoGn5yjgkbw0j0/g2XCnQfq7izQiNiFqc6fTp+QDcDHnqDq8I8UEK0P/iJUx4BIyaxJAUX9l/7x6MljA==" ->
          null
      - content_md5          = "ce75d0ddeb64f9df2ea05e30339a06c8" -> null
      - content_sha1         = "d00fbaa40b57ae580cc1b69283185af58b0cd547" -> null
      - content_sha256       = "1891625ddd20d5427f49533c3e1fd9cd09050329774f241cb9d952257273d395" ->
        null
      - content_sha512       =
        "bb56a56d33281a7e728e091bc348f4fe0d970a741fabb8b342236216a73a7d3a7e4037031e7a83abc23c5042b43ff88
          9531e012326b1240517f65ffbc7a3258c" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0777" -> null
      - filename             = "./lake/raw/.keep" -> null
      - id                   = "d00fbaa40b57ae580cc1b69283185af58b0cd547" -> null
    }

Plan: 0 to add, 0 to change, 6 to destroy.

Warning: Some objects will no longer be managed by Terraform

If you apply this plan, Terraform will discard its tracking information for
the following objects, but it will not delete them:
 - terraform_data.prod_db

After applying this plan, Terraform will no longer manage these objects. You
will need to import them into Terraform to manage them again.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The same destroy plan now forgets the database instead: terraform_data.prod_db will no longer be managed by Terraform, but will not be destroyed, and the count drops to 6 to destroy. It blocks nothing, though: a forced replacement orphans the old database and creates a new one.

But both settings live inside the resource block. Let's delete prod.tf and plan.

Delete the file prod.tf.

Terminal
$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # terraform_data.prod_db will be destroyed
  # (because terraform_data.prod_db is not in configuration)
  - resource "terraform_data" "prod_db" {
      - id     = "<uuid>" -> null
      - input  = "sqldb-sales-prod" -> null
      - output = "sqldb-sales-prod" -> null
    }

Plan: 0 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan simply destroys it, because terraform_data.prod_db is not in configuration. So protect it in Azure too, like azure/protect.tf does:

  • Azure lock: CanNotDelete
  • Backups: and restore tested
  • Pipeline identity: can't remove the lock
Real project

I manage the lock from a separate platform configuration, so one pull request can't remove both the protection and the database.

Scenario 7: You inherit a messy two-thousand-line configuration with hand-made resources mixed in. How do you clean it up safely?

Rule one: a refactor changes no infrastructure. Every cleanup plan must show 0 to add, 0 to change, 0 to destroy, apart from imports.

First we put prod.tf back with prevent_destroy. Then we start with an inventory: terraform state list shows what Terraform manages.

prod.tf
resource "terraform_data" "prod_db" {
  input = "sqldb-sales-prod"

  lifecycle {
    prevent_destroy = true
  }
}
Terminal
$ terraform state list
terraform_data.gateway_cbd
terraform_data.gateway_default
terraform_data.landing
terraform_data.prod_db
terraform_data.sales_account
module.lake.local_file.zone["curated"]
module.lake.local_file.zone["raw"]

Anything in the resource groups that is not on this list is unmanaged, or owned by another configuration: import it, or document why not.

Then small pull requests, each with a plan you can read:

  • moved blocks: split files into modules
  • import blocks: bring hand-made resources in
  • removed blocks: hand over what another team owns
  • prevent_destroy: on the data stores, first

Here is one of them. The landing import is done, so import.tf goes, and a moved block gives the resource a clearer name, landing_account. The next hand-made account, stsalesarchive01, comes in with an import block. We run terraform plan.

landing.tf
# made by hand in the portal last year, now managed here
resource "terraform_data" "landing_account" {}

moved {
  from = terraform_data.landing
  to   = terraform_data.landing_account
}
archive.tf
import {
  to = terraform_data.archive
  id = "stsalesarchive01"
}

resource "terraform_data" "archive" {}

Delete the file import.tf.

Terminal
$ terraform plan -refresh=false
terraform_data.archive: Preparing import... [id=stsalesarchive01]
terraform_data.archive: Refreshing state... [id=stsalesarchive01]

Terraform will perform the following actions:

  # terraform_data.archive will be imported
    resource "terraform_data" "archive" {
        id = "stsalesarchive01"
    }

  # terraform_data.landing has moved to terraform_data.landing_account
    resource "terraform_data" "landing_account" {
        id = "stsaleslanding01"
    }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

The plan says terraform_data.archive will be imported and terraform_data.landing has moved to terraform_data.landing_account: 1 to import, 0 to add, 0 to change, 0 to destroy.

Now we apply, and then prove it with a full plan using -detailed-exitcode.

Terminal
$ terraform apply -auto-approve -refresh=false
terraform_data.archive: Preparing import... [id=stsalesarchive01]
terraform_data.archive: Refreshing state... [id=stsalesarchive01]

Terraform will perform the following actions:

  # terraform_data.archive will be imported
    resource "terraform_data" "archive" {
        id = "stsalesarchive01"
    }

  # terraform_data.landing has moved to terraform_data.landing_account
    resource "terraform_data" "landing_account" {
        id = "stsaleslanding01"
    }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
terraform_data.archive: Importing... [id=stsalesarchive01]
terraform_data.archive: Import complete [id=stsalesarchive01]

Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.
$ terraform plan -detailed-exitcode > nightly.txt; echo "exit code: $?"
exit code: 0
$ grep "No changes" nightly.txt
No changes. Your infrastructure matches the configuration.

The apply imports the archive account, and the full plan returns exit code: 0 with No changes.

Safety net

Back up the state before each step, and run plan -detailed-exitcode every night to catch portal changes early.

Key points to remember

  • import blocks bring hand-made resources in: read the plan first
  • moved blocks change addresses without destroy and create
  • removed + destroy = false: forget it, keep it running
  • ignore_changes for one key, create_before_destroy for no gap
  • prevent_destroy + an Azure lock; refactor plans show 0 to destroy

The full script

Here is a Python script that writes all the files and runs every command in order. You need Terraform 1.17 on your PATH; run it in an empty folder.

Show the full script that writes the files and runs every step
Full script - writes the Terraform files and runs every step (Terraform 1.17 on your PATH), run it with Python
# ========================================================================
# Terraform Interview Q&A #7 - Refactoring, import and lifecycle
# TechBrothersIT. Names and sample values are invented for teaching.
# Every step below was run with Terraform 1.17.0; the outputs in the video and the blog post are the real outputs.
# Providers used: hashicorp/random, null, local, time (they create nothing in any cloud). Azure and Databricks
# code in this series is checked with terraform validate only: nothing is created in a real subscription.
# To run it yourself (Linux, macOS or WSL; some steps use grep / cat):
#   1. Install Terraform 1.17 from developer.hashicorp.com/terraform/install and put it on your PATH.
#   2. python Terraform_Interview_QA_07_Code.py   in an empty folder (terraform init downloads the providers).
# ========================================================================

import os, subprocess

def write(path, text):
    if text is None:
        if os.path.exists(path): os.remove(path)
        return
    os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(text.strip("\n") + "\n")

def run(cmd, expect_error=False, allow_error=False, env=None):
    print("\n$ " + cmd, flush=True)
    rc = subprocess.run(cmd, shell=True, env=dict(os.environ, **(env or {}))).returncode
    if rc != 0 and not (expect_error or allow_error):
        raise SystemExit("stopped: this command was expected to succeed")

# Step 0: the starting files
write('main.tf', """\
terraform {
  required_version = ">= 1.17"
  required_providers {
    local = { source = "hashicorp/local", version = "~> 2.9" }
  }
}
""")
write('lake.tf', """\
resource "local_file" "raw" {
  filename = "${path.module}/lake/raw/.keep"
  content  = "raw zone of the sales lake"
}

resource "local_file" "curated" {
  filename = "${path.module}/lake/curated/.keep"
  content  = "curated zone of the sales lake"
}
""")
write('reporting.tf', """\
resource "local_file" "reporting_share" {
  filename = "${path.module}/reporting/share.txt"
  content  = "Power BI export share, owned by the reporting team"
}
""")
write('tags.tf', """\
variable "tags" {
  type = map(string)
  default = {
    env   = "dev"
    owner = "sales-data"
  }
}

# stand-in for the storage account and its tags
resource "terraform_data" "sales_account" {
  input = var.tags
}
""")
write('gateway.tf', """\
variable "gateway_sku" {
  type    = string
  default = "standard"
}

# default: destroy the old one, then create the new one
resource "terraform_data" "gateway_default" {
  triggers_replace = var.gateway_sku
}

resource "terraform_data" "gateway_cbd" {
  triggers_replace = var.gateway_sku

  lifecycle {
    create_before_destroy = true
  }
}
""")
write('prod.tf', """\
resource "terraform_data" "prod_db" {
  input = "sqldb-sales-prod"

  lifecycle {
    prevent_destroy = true
  }
}
""")
write('azure/main.tf', """\
terraform {
  required_version = ">= 1.17"
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 5.11" }
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "sales" {
  name     = "rg-sales-data-dev"
  location = "eastus2"
}

variable "sql_server_id" {
  type = string
}
""")
write('azure/import.tf', """\
locals {
  sub = "/subscriptions/00000000-0000-0000-0000-000000000000"
  rg  = "${local.sub}/resourceGroups/rg-sales-data-dev"
  st  = "providers/Microsoft.Storage/storageAccounts"
}

# the account made by hand in the portal last year
import {
  to = azurerm_storage_account.landing
  id = "${local.rg}/${local.st}/stsaleslanding01"
}
""")
write('azure/storage.tf', """\
resource "azurerm_storage_account" "landing" {
  name                     = "stsaleslanding01"
  resource_group_name      = azurerm_resource_group.sales.name
  location                 = azurerm_resource_group.sales.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  is_hns_enabled           = true
  min_tls_version          = "TLS1_2"
  tags                     = { env = "dev", owner = "sales-data" }

  lifecycle {
    prevent_destroy = true
    ignore_changes  = [tags["CreatedBy"]]
  }
}
""")
write('azure/protect.tf', """\
resource "azurerm_mssql_database" "sales" {
  name      = "sqldb-sales-prod"
  server_id = var.sql_server_id

  lifecycle {
    prevent_destroy = true
  }
}

resource "azurerm_management_lock" "sales_db" {
  name       = "lock-sqldb-sales-prod"
  scope      = azurerm_mssql_database.sales.id
  lock_level = "CanNotDelete"
  notes      = "Production sales database: do not delete"
}
""")
run('terraform fmt -check -recursive')
run('terraform init')
run('terraform apply -auto-approve')
run('terraform -chdir=azure init -backend=false')

# Step 1: The Azure version: an import block with the full resource ID, checked with validate
run('terraform -chdir=azure validate')

# Step 2: An import block, and let Terraform write the configuration
write('import.tf', """\
import {
  to = terraform_data.landing
  id = "stsaleslanding01"
}
""")
run('terraform plan -refresh=false -generate-config-out=generated.tf > plan.txt')
run("grep -E 'Preparing import|will be imported|config will be generated|Plan:' plan.txt")
run('cat generated.tf')

# Step 3: Review the generated code, keep it in your own file, apply
write('generated.tf', None)
write('landing.tf', """\
# made by hand in the portal last year, now managed here
resource "terraform_data" "landing" {}
""")
run('terraform apply -auto-approve -refresh=false')

# Step 4: Split lake.tf into a module, without moved blocks
write('lake.tf', """\
module "lake" {
  source = "./modules/lake"
  zones  = ["raw", "curated"]
}
""")
write('modules/lake/main.tf', """\
variable "zones" {
  type        = set(string)
  description = "Lake zones, one folder each."
}

resource "local_file" "zone" {
  for_each = var.zones

  filename = "${path.root}/lake/${each.key}/.keep"
  content  = "${each.key} zone of the sales lake"
}
""")
run('terraform get')
run('terraform plan -refresh=false')

# Step 5: Add moved blocks: plan, apply, state list
write('moved.tf', """\
moved {
  from = local_file.raw
  to   = module.lake.local_file.zone["raw"]
}

moved {
  from = local_file.curated
  to   = module.lake.local_file.zone["curated"]
}
""")
run('terraform plan -refresh=false')
run('terraform apply -auto-approve -refresh=false')
run('terraform state list')

# Step 6: A removed block with destroy = false
write('reporting.tf', None)
write('removed.tf', """\
removed {
  from = local_file.reporting_share

  lifecycle {
    destroy = false
  }
}
""")
run('terraform plan -refresh=false')

# Step 7: Apply: the file stays, Terraform forgets it
run('terraform apply -auto-approve -refresh=false')
run('terraform state list')
run('cat reporting/share.txt')

# Step 8: Azure Policy adds a CreatedBy tag (played by a script that writes it into the state); the plan wants to remove it
write('policy.py', """\
# Plays Azure Policy: adds a CreatedBy tag to the stored object,
# the way a refresh records a tag that was added outside Terraform.
import json

state = json.load(open("terraform.tfstate"))
for r in state["resources"]:
    if r["name"] == "sales_account":
        attrs = r["instances"][0]["attributes"]
        for key in ("input", "output"):
            attrs[key]["value"]["CreatedBy"] = "azure-policy"
state["serial"] += 1
json.dump(state, open("terraform.tfstate", "w"), indent=2)
""")
run('python3 policy.py')
run('terraform plan -refresh=false')

# Step 9: ignore_changes on that one tag
write('tags.tf', """\
variable "tags" {
  type = map(string)
  default = {
    env   = "dev"
    owner = "sales-data"
  }
}

# stand-in for the storage account and its tags
resource "terraform_data" "sales_account" {
  input = var.tags

  lifecycle {
    ignore_changes = [input["CreatedBy"]]
  }
}
""")
run('terraform plan -refresh=false')

# Step 10: A change that forces replacement: default vs create_before_destroy
write('gateway.tf', """\
variable "gateway_sku" {
  type    = string
  default = "premium"
}

# default: destroy the old one, then create the new one
resource "terraform_data" "gateway_default" {
  triggers_replace = var.gateway_sku
}

resource "terraform_data" "gateway_cbd" {
  triggers_replace = var.gateway_sku

  lifecycle {
    create_before_destroy = true
  }
}
""")
run('terraform plan -refresh=false')

# Step 11: Apply: the order of create and destroy
run('terraform apply -auto-approve -refresh=false > apply.txt')
run('grep "gateway_default: " apply.txt')
run('grep "gateway_cbd: " apply.txt')

# Step 12: terraform destroy with prevent_destroy
run('terraform destroy -refresh=false', expect_error=True)   # EXPECTED TO FAIL: the video shows this error

# Step 13: lifecycle destroy = false (Terraform 1.16+): a destroy plan forgets instead
write('prod.tf', """\
resource "terraform_data" "prod_db" {
  input = "sqldb-sales-prod"

  lifecycle {
    destroy = false
  }
}
""")
run('terraform plan -destroy -refresh=false')

# Step 14: The pitfall: delete the whole resource block and the protection is gone
write('prod.tf', None)
run('terraform plan -refresh=false')

# Step 15: Scenario: inventory what the state manages
write('prod.tf', """\
resource "terraform_data" "prod_db" {
  input = "sqldb-sales-prod"

  lifecycle {
    prevent_destroy = true
  }
}
""")
run('terraform state list')

# Step 16: One cleanup PR: drop the done import block, rename with moved, import the next hand-made resource
write('import.tf', None)
write('landing.tf', """\
# made by hand in the portal last year, now managed here
resource "terraform_data" "landing_account" {}

moved {
  from = terraform_data.landing
  to   = terraform_data.landing_account
}
""")
write('archive.tf', """\
import {
  to = terraform_data.archive
  id = "stsalesarchive01"
}

resource "terraform_data" "archive" {}
""")
run('terraform plan -refresh=false')

# Step 17: Apply, then prove the code and the real world match
run('terraform apply -auto-approve -refresh=false')
run('terraform plan -detailed-exitcode > nightly.txt; echo "exit code: $?"')
run('grep "No changes" nightly.txt')
Next in this series

Part 8: Terraform for an Azure Data Platform

The sample data on this page is invented for teaching. Every terminal output is the real output of the command above it, run with Terraform 1.17.0; IDs, timestamps and durations are masked. Azure and Databricks code is checked with terraform validate. Part of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT. Features, limits and their status (GA or preview) are current as of October 2026; check the official Terraform documentation (developer.hashicorp.com/terraform) for anything you quote in an interview.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.