Plan & Apply Lifecycle
Every Terraform run boils down to a single question: how does the world you described in HCL differ from the world that actually exists? Terraform answers it by reconciling three pictures of reality—your configuration, its recorded state, and the live infrastructure—then computing a precise diff of the actions needed to converge them. Understanding this plan-and-apply cycle is the key to predicting exactly what terraform apply will do before it does it. The same model applies to OpenTofu, which shares Terraform’s execution semantics and CLI surface.
The three sources of truth
Terraform reasons about three distinct representations of your infrastructure. A plan is the result of comparing them.
| Source | What it is | How Terraform reads it |
|---|---|---|
| Desired state | Your .tf configuration | Parsed from HCL files in the working directory |
| Current state | The state file (terraform.tfstate or remote backend) | Loaded at the start of every command |
| Real world | Actual resources at the provider | Queried during the refresh step |
The configuration says what you want. The state file is Terraform’s memory of what it last created. The refresh step reconciles that memory against what the provider reports right now, catching out-of-band changes (drift) before the diff is computed.
What happens during terraform plan
A plan executes in well-defined phases:
- Init checks – verifies the backend is configured and providers/modules are installed (
terraform initmust have run first). - Refresh – for each resource in state, Terraform calls the provider’s read API to get the latest attributes. This updates the in-memory state without writing to disk.
- Diff – Terraform builds a dependency graph, then for every resource compares refreshed state against the configuration to produce a set of planned actions.
- Output – the proposed changes are printed and, optionally, saved to a plan file.
terraform plan -out=tfplan
Saving the plan to a file makes the subsequent apply deterministic: it applies exactly what you reviewed, with no second diff against a world that may have changed in the meantime.
Reading the diff symbols
Terraform annotates each planned change with a leading symbol. Learning these makes plan output instantly scannable.
| Symbol | Action | Meaning |
|---|---|---|
+ | create | Resource is in config but not in state |
~ | update in place | Attribute changed; provider supports in-place update |
- | destroy | Resource is in state but removed from config |
-/+ | replace | A change forces destroy-then-create (immutable attribute) |
<= | read | Data source will be read during apply |
Consider this configuration change—renaming a bucket’s tag and tightening an immutable setting:
resource "aws_s3_bucket" "logs" {
bucket = "devcraftly-app-logs"
tags = {
Environment = "production"
Team = "platform"
}
}
resource "aws_instance" "api" {
ami = "ami-0c7217cdde317cfec"
instance_type = "t3.medium"
tags = {
Name = "api-server"
}
}
Running a plan after editing the tag and changing the AMI yields:
terraform plan
Output:
Terraform will perform the following actions:
# aws_instance.api must be replaced
-/+ resource "aws_instance" "api" {
~ ami = "ami-0abc..." -> "ami-0c7217cdde317cfec" # forces replacement
~ id = "i-0123456789abcdef0" -> (known after apply)
instance_type = "t3.medium"
~ public_ip = "54.12.34.56" -> (known after apply)
tags = {
"Name" = "api-server"
}
}
# aws_s3_bucket.logs will be updated in-place
~ resource "aws_s3_bucket" "logs" {
id = "devcraftly-app-logs"
~ tags = {
~ "Team" = "infra" -> "platform"
}
}
Plan: 1 to add, 1 to change, 1 to destroy.
The # forces replacement comment is the most important line to watch: it warns that changing ami cannot be done in place, so Terraform will destroy and recreate the instance. The summary line—1 to add, 1 to change, 1 to destroy—is your at-a-glance contract for the apply.
Watch the replacements. A
-/+on a stateful resource (database, EBS volume, EIP) can mean data loss or downtime. Always read why a replace was triggered before approving. Usecreate_before_destroyin alifecycleblock to minimize downtime where the provider allows it.
What happens during terraform apply
terraform apply runs the same plan logic and then executes it. When given a saved plan file it skips the diff entirely and applies the stored changes:
terraform apply tfplan
Without a plan file, apply computes a fresh plan, prompts for confirmation, then walks the dependency graph—creating, updating, and destroying resources in an order that respects their relationships, parallelizing independent operations (10 at a time by default).
Output:
aws_s3_bucket.logs: Modifying... [id=devcraftly-app-logs]
aws_s3_bucket.logs: Modifications complete after 1s
aws_instance.api: Destroying... [id=i-0123456789abcdef0]
aws_instance.api: Destruction complete after 32s
aws_instance.api: Creating...
aws_instance.api: Creation complete after 41s [id=i-0fedcba9876543210]
Apply complete! Resources: 1 added, 1 changed, 1 destroyed.
After each successful operation, Terraform writes the new attributes back to the state file, keeping its memory in sync with the world it just changed.
Targeted and speculative runs
Two flags refine the lifecycle for everyday work:
# Preview without ever touching real resources (refresh + diff only)
terraform plan
# Limit the operation to a subset of the graph (use sparingly)
terraform apply -target=aws_s3_bucket.logs
# Skip the refresh step when you trust state is current (faster)
terraform plan -refresh=false
-target is an escape hatch for recovering from partial failures, not a routine workflow—it bypasses the full dependency graph and can leave config and state inconsistent.
Best Practices
- Always run
terraform planand read the summary line before applying; never apply blind in shared environments. - Use
terraform plan -out=tfplanfollowed byterraform apply tfplanin CI so the applied changes match exactly what was reviewed. - Treat every
-/+replace as a yellow flag—confirm it won’t destroy stateful data or cause downtime. - Keep refresh enabled in normal runs so drift surfaces in the plan; only use
-refresh=falsewhen you have a specific performance reason. - Avoid
-targetexcept for break-glass recovery; relying on it hides real dependency problems. - Store state in a remote backend with locking so concurrent plans and applies can’t corrupt the current-state picture.