Operation #
Every resource in a Terraform configuration will undergo one of five operations: create, read, update, delete, or replace. Terraform decides which operation is right based on a comparison of the configuration, state, and actual cloud condition. Understanding the logic behind this decision — including why an update can sometimes turn into a replace — helps you write more predictable configurations and avoid surprises at apply time.
The Five Resource Operations #
flowchart TD
A["Configuration .tf\nvs State\nvs Actual condition"] --> B{"Where is\nthe resource?"}
B -->|"In config,\nnot in state"| C["+ CREATE\nNew resource"]
B -->|"In config\nand state"| D{"Any changes?"}
B -->|"In state,\nnot in config"| E["- DELETE\nDelete the resource"]
B -->|"Data source"| F["<= READ\nQuery data"]
D -->|"Yes, can be\ndone in-place"| G["~ UPDATE\nModify"]
D -->|"Yes, cannot\nbe done in-place"| H["-/+ REPLACE\nDestroy + Create"]
style C fill:#e8f5e9,stroke:#2e7d32
style D fill:#e3f2fd,stroke:#1565c0
style E fill:#ffebee,stroke:#c62828
style F fill:#fff3e0,stroke:#e65100
style G fill:#e3f2fd,stroke:#1565c0
style H fill:#fff3e0,stroke:#e65100CREATE (+)
Condition: The resource is in the configuration, not in state
Action: Terraform calls the provider API to create a new resource
Plan symbol: +
READ (<=)
Condition: A data source is read
Action: Terraform queries data from the provider, no infrastructure change
Plan symbol: <=
UPDATE IN-PLACE (~)
Condition: The resource is in both configuration and state, but the config differs
AND the provider supports in-place updates for the changed attributes
Action: Terraform calls the update API — the resource keeps running
Plan symbol: ~
DELETE (-)
Condition: The resource is in state, not in the configuration
Action: Terraform calls the provider API to delete the resource
Plan symbol: -
REPLACE (-/+)
Condition: The resource needs updating BUT the change
can't be done in-place (the provider doesn't support it)
Action: Delete the old resource, create a new one
Plan symbol: -/+ (or +/- if create_before_destroy is active)
| Operation | Symbol | Resource in Config? | Resource in State? | Example Action |
|---|---|---|---|---|
| Create | + | ✅ | ❌ | Create a new EC2 |
| Read | <= | — (data) | — | Read an AMI |
| Update in-place | ~ | ✅ | ✅ | Change a tag |
| Delete | - | ❌ | ✅ | Delete a VPC |
| Replace | -/+ | ✅ | ✅ | Change the AMI |
How Terraform Chooses an Operation #
Whether a change can be done in-place or must be a replace is decided by the provider, not Terraform core.
flowchart TD
A["Change detected\nin the plan"] --> B["Provider checks:\nwhat's the nature of\nthis attribute?"]
B -->|"updatable"| C["UPDATE IN-PLACE (~)\nThe resource stays\nOnly the attribute changes"]
B -->|"forces new resource"| D["REPLACE (-/+)\nOld resource deleted\nNew resource created"]
C --> E["Examples:\nchange tags, change size\nwithout restart"]
D --> F["Examples:\nchange AMI, change\ncertain instance types"]
style C fill:#e8f5e9,stroke:#2e7d32
style D fill:#fff3e0,stroke:#e65100# Every resource argument has a nature defined by the provider:
# - "updatable" → changes trigger an UPDATE in-place
# - "forces new resource" → changes trigger a REPLACE
resource "aws_instance" "web" {
ami = var.ami_id # forces new resource — can't be replaced in-place
instance_type = var.type # updatable — can be changed in-place (with stop/start)
tags = {
Name = "web" # updatable — can be changed without restarting the instance
}
root_block_device {
volume_size = 30 # forces new resource in some conditions
}
}
# The plan output tells you which attributes force a replacement:
# -/+ resource "aws_instance" "web" {
# ~ ami = "ami-OLD" -> "ami-NEW" # forces replacement
# id = "i-0abcdef1234567890"
# }
#
# The "# forces replacement" or "forces new resource" marker
# appears next to the attribute causing the replace
| AWS Instance Attribute | Nature | Operation When Changed |
|---|---|---|
tags | updatable | Update in-place |
ami | forces new resource | Replace |
instance_type | updatable (stop/start) | Update in-place |
subnet_id | forces new resource | Replace |
user_data | forces new resource | Replace |
root_block_device.volume_size | updatable | Update in-place |
Import: Taking Over Existing Resources #
One important operation often needed when migrating to Terraform is import — taking over management of resources that already exist in the cloud without deleting and recreating them.
flowchart TD
A["Resource already exists\nin the cloud\nwithout Terraform"] --> B["1. Create the\nresource block in .tf"]
B --> C["2. Run\nterraform import"]
C --> D["3. State updated\nwith the\nexisting resource"]
D --> E["4. Check with\nterraform state show"]
E --> F["5. Complete the\n.tf configuration"]
F --> G["6. terraform plan\nShould be 'No changes'"]
style A fill:#fff3e0,stroke:#e65100
style G fill:#e8f5e9,stroke:#2e7d32# Format: terraform import <resource_address> <resource_id>
# Import an existing EC2 instance
terraform import aws_instance.web i-0abcdef1234567890
# Import an existing VPC
terraform import aws_vpc.main vpc-0abcdef1234567890
# Import an S3 bucket
terraform import aws_s3_bucket.assets my-existing-bucket
# Import a resource inside a module
terraform import module.vpc.aws_vpc.main vpc-0abcdef1234567890
# Before importing, you must already have a resource block
# (either empty or with the configuration you want)
# Step 1: Create the resource block (can be empty first)
resource "aws_instance" "web" {
# Fill this in after importing, based on terraform state show output
}
# Step 2: Import
# terraform import aws_instance.web i-0abcdef1234567890
# Step 3: View the actual condition in state
# terraform state show aws_instance.web
# Step 4: Complete the configuration based on the actual condition
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890" # from state show
instance_type = "t3.micro" # from state show
subnet_id = "subnet-0abcdef" # from state show
}
# Step 5: Run plan — should be "No changes"
# terraform plan
Importing with the import Block (Terraform 1.5+) #
Since Terraform 1.5, there’s a more declarative way to import using an import block in the configuration.
# Import blocks can be committed to version control
# and executed as part of terraform apply
import {
to = aws_instance.web
id = "i-0abcdef1234567890"
}
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id
}
# Terraform 1.5+ can also auto-generate configuration:
# terraform plan -generate-config-out=generated.tf
# Produces a .tf file with configuration based on the actual condition
| Import Method | Command/Block | Committable? | Generate Config? |
|---|---|---|---|
| CLI | terraform import <addr> <id> | ❌ | ❌ |
import block (1.5+) | import { to = ... id = ... } | ✅ | ✅ (-generate-config-out) |
Moved Block: Renaming Resources Without Destroy #
When you want to rename a resource in the configuration, Terraform will by default delete the old one and create a new one. The moved block prevents this.
flowchart LR
subgraph "Without moved"
A1["aws_instance.web"] --> B1["Rename to\naws_instance.web_server"]
B1 --> C1["Plan: -web +web_server\nDESTROY + CREATE ❌"]
end
subgraph "With moved"
A2["aws_instance.web"] --> B2["moved {\n from = web\n to = web_server\n}"]
B2 --> C2["Plan: moved\nNo changes ✅"]
end
style C1 fill:#ffebee,stroke:#c62828
style C2 fill:#e8f5e9,stroke:#2e7d32# PROBLEM: Renaming a resource causes destroy + create
# Before:
resource "aws_instance" "web" { ... }
# After renaming:
resource "aws_instance" "web_server" { ... }
# Plan: -aws_instance.web + aws_instance.web_server → DESTROY + CREATE!
# SOLUTION: Use a moved block
moved {
from = aws_instance.web
to = aws_instance.web_server
}
resource "aws_instance" "web_server" {
ami = var.ami_id
instance_type = "t3.micro"
}
# Plan: aws_instance.web moved to aws_instance.web_server
# → NO destroy, no create
# moved is also useful when refactoring into a module
moved {
from = aws_vpc.main
to = module.networking.aws_vpc.main
}
Tainted Resources #
A resource marked “tainted” by Terraform is considered broken and needs to be replaced on the next apply.
# Mark a resource as tainted (it will be replaced on apply)
terraform taint aws_instance.web
# Remove the taint if you change your mind
terraform untaint aws_instance.web
# Check for tainted resources in state
terraform state list # Tainted resources are marked with (tainted)
# Cases where resources are automatically tainted:
# - A provisioner fails during create
# - The resource was created but is in an error state
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = "t3.micro"
provisioner "remote-exec" {
inline = ["sudo apt update && sudo apt install -y nginx"]
connection {
type = "ssh"
user = "ubuntu"
host = self.public_ip
}
}
# If the provisioner fails, this instance is automatically tainted
# The next terraform apply will destroy + recreate this instance
}
| Scenario | Auto-Taint? | Action |
|---|---|---|
| Provisioner fails during create | ✅ Yes | The next apply will replace |
| Resource errors during create | ✅ Yes | The next apply will replace |
Manual terraform taint | — | You’re the one marking it |
terraform untaint | — | Removes the mark, no replace |
Operation Ordering and the Dependency Graph #
# Terraform determines the operation order based on the dependency graph
# Resources without dependencies can be created in parallel
# Visualize the dependency graph
terraform graph | dot -Tpng > graph.png
terraform graph | dot -Tsvg > graph.svg
flowchart TD
A["aws_vpc.main"] --> B["aws_subnet.public"]
A --> C["aws_subnet.private"]
B --> D["aws_instance.web"]
C --> E["aws_db_instance.main"]
D --> F["aws_lb_target_group_attachment"]
style A fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#e8f5e9,stroke:#2e7d32Summary #
- Five operations: create
+, read<=, update in-place~, delete-, replace-/+— Terraform chooses based on a comparison of configuration, state, and actual condition.- Whether it’s an update or replace is determined by the provider, not Terraform core — check the provider docs to know which attributes “force new resource”.
terraform importto take over existing resources without destroying them — you need a resource block in place before importing.- The declarative
importblock (Terraform 1.5+) is cleaner than the CLI command and can be committed to version control.- The
movedblock for renaming resources or refactoring into modules without causing destroy + create.- Tainted resources are resources considered broken and will be replaced on the next apply — they can be marked manually or automatically when a provisioner fails.