Terraform init, validate, and plan: the core workflow explained
The core Terraform workflow is Write, Plan, Apply: you write configuration in HCL, preview the changes Terraform intends to make with terraform plan, then execute them with terraform apply. Before either of those can run, terraform init prepares the working directory — downloading provider plugins, installing modules, configuring the backend, and writing the dependency lock file — and terraform validate gives you a fast check that the configuration is syntactically valid and internally consistent without contacting any remote API. The exam tests this sequence heavily. You need to know exactly what each command does and does not do, which commands read or write real infrastructure, what the +, ~, and - symbols mean in plan output, and how -out saves a plan so a later apply executes exactly what you reviewed. This lesson covers init, validate, and plan in depth; apply, destroy, and fmt follow in the next lesson.
On this page8 sections
- The Write, Plan, Apply workflow
- terraform init: initializing a working directory
- What init installs: providers, modules, and the lock file
- Backend initialization and the init flags you must know
- terraform validate: fast consistency checks without cloud access
- terraform plan: generating an execution plan
- Reading plan output: +, ~, -, and replacement
- Saving a plan with -out
- Describe the Write, Plan, Apply workflow and map each stage to a Terraform command
- Explain what terraform init installs and configures, including providers, modules, the backend, and the .terraform.lock.hcl file
- Use terraform validate to check syntax and internal consistency without reaching cloud APIs
- Generate an execution plan with terraform plan and interpret the +, ~, -, and -/+ action symbols
- Save a plan with -out and explain why applying a saved plan file guarantees the reviewed changes
- Choose the right init flags (-upgrade, -reconfigure, -migrate-state, -backend=false) for common scenarios
The Write, Plan, Apply workflow
The core Terraform workflow has three stages: Write configuration as code, Plan to preview the changes Terraform will make, and Apply to execute them. This loop is deliberate: because your infrastructure is declared in files, every change can be reviewed as a diff before it touches real resources. You repeat the loop for every change — edit the configuration, plan, review, apply — whether you work alone on a laptop or in a team with version control and CI.
Two supporting commands wrap the loop. terraform init is a one-time (per working directory, repeated only when dependencies change) setup step that must succeed before plan or apply can run. terraform validate is a quick correctness check you can run at any point after init, typically as the first gate in a CI pipeline. Neither of these touches real infrastructure.
| Stage | Command | What it does | Touches infrastructure? |
|---|---|---|---|
| Set up | terraform init | Downloads providers and modules, configures the backend, writes the lock file | No (backend/registry access only) |
| Check | terraform validate | Verifies syntax and internal consistency | No |
| Plan | terraform plan | Compares configuration to state and proposes actions | Reads only (refresh) |
| Apply | terraform apply | Executes the plan and updates state | Yes |
On the exam, expect questions that give you a scenario — a fresh clone of a repository, a newly added provider, an edited resource — and ask which command comes next. The answer is almost always driven by this table: fresh directory or new dependency means init; wanting to preview means plan; making it real means apply.
terraform init: initializing a working directory
terraform init initializes a working directory containing Terraform configuration, and it is the first command you run in any new or freshly cloned configuration. Until init succeeds, terraform plan and terraform apply fail, because the provider plugins they need have not been installed. Running init performs four jobs: it configures the backend where state will be stored, downloads the provider plugins the configuration requires, installs any modules the configuration calls, and creates or updates the dependency lock file.
$ terraform init Initializing the backend... Initializing provider plugins... - Finding hashicorp/aws versions matching "~> 5.0"... - Installing hashicorp/aws v5.98.0... - Installed hashicorp/aws v5.98.0 (signed by HashiCorp) Terraform has created a lock file .terraform.lock.hcl to record the provider selections it made above. Include this file in your version control repository. Terraform has been initialized successfully!
Init is safe to run repeatedly — it never destroys infrastructure or modifies your configuration, so rerunning it on an already-initialized directory is a no-op apart from checking dependencies. You must run it again whenever the dependencies of the directory change: you add a provider requirement, add or change a module block or a module's source or version, or change the backend configuration. Terraform will tell you when this is needed — plan fails with an error asking you to run terraform init.
What init installs: providers, modules, and the lock file
Init downloads two kinds of dependency into the hidden .terraform directory. Provider plugins — the binaries that actually talk to AWS, Google Cloud, Azure, or utility providers like random — are installed under .terraform/providers, selected from the version constraints in your required_providers block. Modules referenced by module blocks are fetched into .terraform/modules, whether they come from the public registry, a Git URL, or a local path. The .terraform directory is machine-local and should never be committed to version control.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}Init also writes .terraform.lock.hcl, the dependency lock file. It records the exact provider versions selected and their checksums, so every teammate and every CI run installs identical provider builds. Unlike .terraform, the lock file should be committed to version control. Two exam traps here: the lock file locks providers only — module versions are not recorded in it — and Terraform will not silently drift to a newer provider once the lock file exists. To deliberately upgrade to the newest versions allowed by your constraints, run terraform init -upgrade, which reselects versions and rewrites the lock file.
Backend initialization and the init flags you must know
Init configures the backend — where Terraform stores state — from the backend block inside the terraform block (or the local backend by default). Because backend settings cannot contain variables, partial configuration is common: you supply the rest at init time with -backend-config, either as key=value pairs or a file.
$ terraform init -backend-config="bucket=my-tf-state" -backend-config="region=eu-west-1"
When you change the backend configuration, plain init refuses to guess your intent and asks you to choose: terraform init -migrate-state copies existing state from the old backend to the new one, while terraform init -reconfigure disregards the existing backend configuration entirely and initializes against the new one without migrating anything.
-upgrade— upgrade providers and modules to the newest versions allowed by constraints, updating the lock file.-migrate-state— move existing state to a newly configured backend.-reconfigure— reinitialize the backend, ignoring any previously saved configuration and skipping migration.-backend-config=...— supply partial backend configuration at init time.-backend=false— skip backend initialization entirely; useful in CI jobs that only need to runvalidate.
Expect scenario questions such as "you moved state storage from local to an S3 bucket and want to keep your existing resources — what do you run?" The answer is terraform init -migrate-state.
terraform validate: fast consistency checks without cloud access
terraform validate checks that a configuration is syntactically valid and internally consistent: HCL parses, argument names exist for the resource types used, references point at things that are declared, and types line up. Crucially, it does all of this without contacting any remote service — it never reads state, never calls a cloud API, and never checks whether the values you supplied would actually be accepted by the provider's service. That makes it fast and safe to run anywhere, and it is the standard first gate in a CI pipeline.
$ terraform validate
Success! The configuration is valid.
$ terraform validate -json
{
"format_version": "1.0",
"valid": true,
"error_count": 0,
"warning_count": 0,
"diagnostics": []
}One dependency trips people up on the exam: validate needs the provider schemas to know which arguments are legal, so the working directory must be initialized first. In a CI job that has no backend credentials, initialize without the backend and then validate: terraform init -backend=false followed by terraform validate. The -json flag produces machine-readable output for tooling.
Keep validate's limits straight: it will catch a typo like istance_type or a reference to an undeclared variable, but it will not catch an AMI ID that does not exist, a name that collides with a real resource, or missing IAM permissions — those surface at plan or apply time, when Terraform actually talks to the provider.
terraform plan: generating an execution plan
terraform plan creates an execution plan: it reads the current state, refreshes Terraform's view of the real objects recorded there, compares that against your configuration, and proposes the minimal set of actions — create, update, or destroy — needed to make reality match the code. Plan is a read-only preview: it never modifies infrastructure and never writes to the state file. Even the refresh it performs by default only updates Terraform's in-memory view for the comparison; the stored state is untouched until apply.
resource "random_pet" "suffix" {
length = 2
}
resource "aws_instance" "web" {
ami = "ami-0c101f26f147fa7fd"
instance_type = "t3.micro"
tags = {
Name = "web-${random_pet.suffix.id}"
}
}$ terraform plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c101f26f147fa7fd"
+ instance_type = "t3.micro"
+ id = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.Values Terraform cannot know until the resource actually exists — server-assigned IDs, IP addresses — show as (known after apply). Useful flags: -var and -var-file set input variables, -refresh=false skips the refresh for speed, -refresh-only plans only the reconciliation of state with reality (no configuration changes), -destroy previews a full teardown, and -detailed-exitcode makes plan exit with code 0 for no changes, 1 for error, and 2 for changes present — the standard way CI detects drift or pending changes.
Reading plan output: +, ~, -, and replacement
Every proposed action in plan output is marked with a symbol, and the exam expects you to read them at a glance. + means create, ~ means update in-place (the resource survives, an argument changes), and - means destroy. When a changed argument cannot be updated in place — for example changing an aws_instance AMI — Terraform must replace the resource, shown as -/+ (destroy then create) with a note like "forces replacement" beside the offending argument. Data sources that will be read during apply are marked <=.
| Symbol | Action | Meaning |
|---|---|---|
+ | Create | New resource will be provisioned |
~ | Update in-place | Existing resource modified without recreation |
- | Destroy | Resource will be removed |
-/+ | Replace | Destroyed and re-created because a change forces replacement |
<= | Read | Data source will be read during apply |
The summary line — Plan: 1 to add, 1 to change, 1 to destroy — counts these actions, and a replacement counts as both one add and one destroy. Reviewing for unexpected - and -/+ lines before approving is the whole point of the plan stage: an innocent-looking edit that forces replacement of a database is exactly the kind of surprise plan exists to catch.
Saving a plan with -out
terraform plan -out=FILE saves the generated plan to a file, and terraform apply FILE later executes exactly those actions — no more prompting, and no risk that the world or the configuration changed between review and execution. Without a saved plan, terraform apply computes a fresh plan at apply time, which may differ from the one you looked at. Saved plans are the backbone of CI/CD pipelines: the plan job produces the file and posts the diff for review; the apply job consumes the same file after approval.
$ terraform plan -out=tfplan $ terraform show tfplan # human-readable review of the saved plan $ terraform show -json tfplan # machine-readable JSON for policy tools $ terraform apply tfplan
Three exam-relevant details. First, the plan file is a binary format — you inspect it with terraform show, not a text editor — and it can contain sensitive values, so treat it like a secret. Second, input variable values are captured inside the saved plan, so you do not (and must not) pass -var again at apply time. Third, a saved plan is checked against the state it was created from: if the state has changed since the plan was written, Terraform refuses to apply the stale plan and you must plan again. Applying a saved plan file also skips the interactive approval prompt, because the review already happened when the plan was generated.
Tip. The exam gives workflow scenarios and asks which command comes next: a fresh clone needs init, a changed backend needs init -migrate-state or -reconfigure, and CI validation needs init -backend=false before validate. Expect questions on what validate does and does not check, on reading the +, ~, -, and -/+ plan symbols, and on the behavior of saved plans — that apply tfplan skips the prompt and executes exactly the reviewed actions. Know that plan never modifies infrastructure or state, and that the lock file pins providers, not modules.
- The core workflow is Write, Plan, Apply — plan previews changes as a diff, apply executes them, and the loop repeats for every change.
- terraform init must run first in any new or cloned directory, and again whenever providers, modules, or the backend configuration change; it is always safe to rerun.
- Init writes .terraform.lock.hcl, which pins provider versions and checksums and belongs in version control; the .terraform directory does not. The lock file covers providers only, never modules.
- terraform validate checks syntax and internal consistency offline — it needs provider schemas from init (use init -backend=false in CI) but never contacts cloud APIs or reads state.
- terraform plan is read-only: it refreshes in memory, compares state to configuration, and proposes actions without modifying infrastructure or the stored state.
- Plan symbols: + create, ~ update in-place, - destroy, -/+ replace (forced when an argument cannot change in place); replacements count as one add plus one destroy.
- terraform plan -out=tfplan saves a binary plan; terraform apply tfplan executes exactly what was reviewed, skips the approval prompt, embeds variable values, and fails if the state has changed since.
- plan -detailed-exitcode returns 2 when changes are pending — the standard CI mechanism for detecting drift.
Frequently asked questions
When do I need to run terraform init again?
Run init again whenever the working directory's dependencies change: you add or change a provider requirement, add a module block or change a module's source or version, or modify the backend configuration. Terraform detects the mismatch and refuses to plan until you reinitialize. Rerunning init on an unchanged directory is harmless — it never touches infrastructure.
Does terraform validate check my cloud credentials or whether resources exist?
No. Validate checks only syntax and internal consistency — legal argument names, resolvable references, matching types. It never authenticates to a provider, reads state, or verifies that values like AMI IDs actually exist. Errors of that kind appear only at plan or apply time, when Terraform contacts the provider's API.
Does terraform plan change my state file?
No. Plan reads the state and refreshes Terraform's view of real resources in memory to compute the diff, but it writes nothing — neither infrastructure nor the state file changes. Only terraform apply (or state-manipulation commands) modifies state. If you want to persist refreshed values without changing infrastructure, that is what terraform apply -refresh-only is for.
What does -/+ mean in plan output?
It marks a replacement: the resource will be destroyed and then re-created because a changed argument cannot be updated in place. Terraform annotates the specific argument with a note that it forces replacement — for example, changing the AMI of an aws_instance. In the plan summary, a replacement is counted as one resource to add and one to destroy.
Why apply a saved plan file instead of just running terraform apply?
A plain terraform apply generates a fresh plan at that moment, which can differ from the plan you reviewed if the configuration or real infrastructure changed in between. Applying a saved plan file guarantees Terraform performs exactly the reviewed actions, skips the approval prompt, and refuses to run if the state has changed since the plan was created. That review-then-apply-the-same-artifact pattern is standard in CI/CD.
Should .terraform.lock.hcl be committed to version control?
Yes. The lock file records the exact provider versions and checksums init selected, so teammates and CI install identical provider builds. Commit it alongside your configuration. The .terraform directory itself, which holds the downloaded plugin binaries and module copies, is machine-local and should be gitignored. To intentionally move to newer provider versions, run terraform init -upgrade.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.