TerraformVII · Resources, Data Sources, and count/for_eachProduction Terraform
CRUD and the Resource Lifecycle
What you'll learn
- Explain the four CRUD operations Terraform performs against a provider API
- Read the plan symbols +, ~, -/+, +/- and - and map them to lifecycle actions
- Distinguish in-place update from destroy-then-create replacement
- Recognise the role of refresh in the read phase of an apply
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
When you run terraform apply against a configuration, the tool performs
the four CRUD operations against your provider: it creates resources
that do not exist, reads the state of resources that do, updates the
ones whose configuration has drifted, and destroys the ones that have
been removed from the configuration. The vocabulary is borrowed from
databases; the mechanics are not. This lesson is the operational
ground truth for what terraform apply actually does at the API
layer.
The four operations
CRUD stands for Create, Read, Update, Delete. In Terraform:
- Create — a resource block exists in the configuration but no
state entry exists. Terraform calls the provider’s
CreateAPI. - Read — Terraform calls the provider’s
Read(orRefresh) API to compare the live resource against the state. This happens during the plan phase and during the first half of the apply phase. - Update — the configuration and state disagree on a non-
ForceNewattribute. Terraform calls the provider’sUpdateAPI to mutate the resource in place. - Delete — the resource block has been removed from the
configuration, or
count/for_eachshrank, or the resource has been marked for replacement. Terraform calls the provider’sDeleteAPI.
Configuration State Live infrastructure
| | |
| Read (refresh) | Read (API call) |
|<-----------------+------------------------->|
| | |
| Compare | |
+----------------->| |
| | |
| Action: Create / Update / Delete / Replace |
+----------------->|------------------------->|
The plan is the comparison step. The apply is the action step.
Plan symbols and what they mean
terraform plan annotates every change with a symbol. The symbols are
terse, but each one is precise:
| Symbol | Meaning | API action |
|---|---|---|
+ | Resource will be created | Create |
~ | Resource will be updated in place | Update |
- | Resource will be destroyed | Delete |
-/+ | Destroy then create (forced replacement) | Delete + Create |
+/- | Create then destroy (create_before_destroy) | Create + Delete |
<= | Data source will be read | Read |
A real example. Edit instance_type from t3.small to t3.medium
on an EC2 instance:
terraform plan
# aws_instance.web will be updated in-place
~ resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
~ instance_type = "t3.small" -> "t3.medium"
id = "i-0abcd1234ef567890"
+ tags = {
+ "Name" = "web-1"
}
# (14 unchanged attributes hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy.
Now change the AMI:
terraform plan
# aws_instance.web must be replaced
-/+ resource "aws_instance" "web" {
~ ami = "ami-0c55b159cbfafe1f0" -> "ami-0a1b2c3d4e5f67890" # forces replacement
id = "i-0abcd1234ef567890"
~ instance_type = "t3.small" -> "t3.medium"
# ...
}
Plan: 0 to add, 1 to change, 1 to destroy.
The must be replaced line and the # forces replacement comment are
provider-supplied. They are the canonical signal that the change is
not safe in-place. The lesson on replacement covers the reasons in
detail.
How to inspect the plan programmatically
For production change reviews, the textual plan is harder to parse
than the JSON form. Use terraform show -json:
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | {address: .address, actions: .change.actions}'
{
"address": "aws_instance.web",
"actions": ["delete", "create"]
}
{
"address": "aws_instance.api",
"actions": ["update"]
}
{
"address": "data.aws_ami.ubuntu",
"actions": ["read"]
}
The actions array is the lifecycle in execution order. A two-element
array is a replace; a single-element array is a single CRUD action;
["no-op"] means the resource is unchanged.
Refresh: the read phase
Before the plan is computed, Terraform runs a refresh. The refresh
calls Read on every resource and updates the state to match the
live infrastructure. Refresh is implicit in plan and apply. It can
be made explicit with terraform refresh (which is now just an alias
for terraform apply -refresh-only).
terraform plan -refresh-only -out=tfplan-refresh
terraform apply tfplan-refresh
The -refresh-only flag updates state without proposing any
configuration changes. It is the right tool when you suspect drift
but you do not want to apply a configuration change.
Production failure modes
-
Misreading
-/+as~. The operator edits an attribute they believe is mutable. The plan shows~(in-place). They do not notice that the provider marks itForceNewin a newer version. The next plan shows-/+. They approve without thinking, and the resource is destroyed. Recovery: restore from the state backup taken before the apply, then re-import if the resource still exists. -
Partial apply.
terraform applycallsCreate, succeeds for resource A, then crashes before resource B is created. State diverges from the configuration: A exists in the world and in state; B exists in neither. Recovery: runterraform plan; the missing B will be re-proposed. Inspect state withterraform state list. -
Concurrent applies. Two operators run
applyat the same time. Both pass the plan stage, both call the provider API. The state lock prevents this with a DynamoDB lock (or local lock); the second apply fails withError acquiring the state lock. Recovery: wait for the first apply to complete. Never disable the state lock. -
terraform refreshmutating state silently.terraform refreshupdates state to match the real world without proposing a plan. If drift is the result of a manual fix and you runrefresh, the fix becomes invisible to subsequent plans. Recovery: useterraform plan -refresh-onlyfirst to see what would change, then decide. -
Approving
applywithout reading.terraform applyaccepts-auto-approve. CI pipelines use it. A misconfigured pipeline can apply a change that destroys production. Recovery: restore from state backup; restore the resource from the provider console if the state is gone.
Security and performance
Security. Every CRUD call hits the provider API. The IAM role
used by Terraform must allow Create, Read, Update, Delete on
every resource type you manage. Most production incidents involving
“permission denied” during apply are IAM scope mismatches that were
not caught in development.
Performance. A large apply issues many API calls. Most providers
rate-limit per account per region. The plan engine parallelises
independent resources, but the apply phase is bounded by the provider
rate limits. For large estates, run terraform apply -parallelism=10
or lower to avoid tripping the limits.
What to do in production
- Always run
terraform plan -out=tfplanand read the output (text or JSON) before approving. - Use a CI pipeline that runs
planon every PR andapplyonly on merge to the main branch. - Configure a remote backend with state locking (S3 + DynamoDB, or Terraform Cloud) before the first team apply.
- Never run
terraform refreshagainst production state without a preceding-refresh-onlyplan. - Take a state backup before every apply to production. The
terraform state pull > backup.tfstatepattern works.
Verification
# 1. Show the JSON plan and confirm the lifecycle actions match expectations
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[].change.actions'
# 2. Confirm the state lock is in place (remote backend assumed)
terraform force-unlock -help 2>&1 | head -1
# 3. Verify state matches reality (no drift)
terraform plan -refresh-only
# 4. Inspect the state for a single resource
terraform state show aws_instance.web
# 5. Confirm no partial apply is in flight
terraform state list
A clean verification looks like:
$ terraform plan -refresh-only
No changes. Your infrastructure matches the configuration.
Any other output means drift exists and the read phase found it.
Knowledge check · 7 questions
Q1. Which plan symbol indicates that a resource will be destroyed and a new one created in its place?
Q2. What does Terraform do during the refresh phase of a plan?
Q3. The textual plan and the JSON plan describe the same lifecycle actions.
Q4. Which of the following are valid plan actions in Terraform 1.9.x? (Select all that apply.)
Q5. Why is `terraform refresh` discouraged as a standalone command in production?
Q6. An operator runs `terraform apply` in production. The apply creates resource A successfully, then the network blip causes the apply to fail before resource B is created. What is the state?
Q7. Which command updates state to match reality without proposing configuration changes?
Passing score: 75%. Answers are checked in this browser.