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:#e65100
CREATE (+)
  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)
OperationSymbolResource 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 AttributeNatureOperation When Changed
tagsupdatableUpdate in-place
amiforces new resourceReplace
instance_typeupdatable (stop/start)Update in-place
subnet_idforces new resourceReplace
user_dataforces new resourceReplace
root_block_device.volume_sizeupdatableUpdate 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 MethodCommand/BlockCommittable?Generate Config?
CLIterraform 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
}
ScenarioAuto-Taint?Action
Provisioner fails during create✅ YesThe next apply will replace
Resource errors during create✅ YesThe next apply will replace
Manual terraform taintYou’re the one marking it
terraform untaintRemoves 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:#2e7d32

Summary #

  • 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 import to take over existing resources without destroying them — you need a resource block in place before importing.
  • The declarative import block (Terraform 1.5+) is cleaner than the CLI command and can be committed to version control.
  • The moved block 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.

← Previous: What is a Resource?   Next: Dependency →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact