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 — configuring the backend, installing provider plugins and modules, 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. To use the workflow well you need to know exactly what each command does and does not do: which changes require re-running init, how the lock file and init -upgrade control provider versions, which init flags handle a backend change, what validate cannot catch, what plan reads and locks, how its exit codes behave in automation, what the +, ~, -, -/+ and +/- symbols mean, 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, and which changes require re-running it
- Control provider and module versions with the lock file, version constraints, and terraform init -upgrade
- Choose the right init flags (-backend-config, -migrate-state, -force-copy, -reconfigure, -backend=false) for backend scenarios
- Use terraform validate to check syntax and internal consistency, and contrast it with fmt -check and plan as CI gates
- Generate an execution plan with terraform plan, use its modes and options, and interpret its exit codes and the +, ~, -, -/+, +/- and <= symbols
- Save a plan with -out and explain why applying a saved plan file guarantees the reviewed changes
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, where the plan is typically posted on a pull request and applied after approval.
Two supporting commands wrap the loop. terraform init is a setup step — run once per working directory and repeated only when its dependencies change — that must succeed before validate, plan or apply can run. terraform validate is a quick correctness check you can run at any point after init, typically as an early gate in a CI pipeline. Neither of these creates, changes or destroys infrastructure.
| Stage | Command | What it does | Touches infrastructure? |
|---|---|---|---|
| Set up | terraform init | Configures the backend, installs providers and modules, writes the lock file | No (backend and 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 |
Deciding which command comes next follows from this table. A fresh clone or a new dependency needs init; a configuration you want checked cheaply needs validate; a change you want to preview needs plan; and only apply makes it real. Editing an existing resource argument needs no init at all — go straight to plan.
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 validate, plan and 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!
What init does not do matters as much. It creates no infrastructure, does not refresh or query your real infrastructure (it contacts only the backend and provider/module registries), and does not format or rewrite your configuration. With the default local backend it does not even create a state file: terraform.tfstate first appears when an apply records resources. Init is safe to run repeatedly — rerunning it on an already-initialized directory just re-checks dependencies.
| Change you made | Re-run init? |
|---|---|
Added a provider to required_providers (or a resource from a new provider) | Yes |
Added a module block, or changed a module's source or version | Yes |
Added or changed the backend configuration | Yes |
Changed a provider configuration argument (for example the region in a provider block) | No — plan picks it up |
| Edited resource arguments, variables or outputs | No |
When init is needed and you skip it, plan or validate fails with an error asking you to run terraform init. Two narrower commands overlap with parts of init, and neither replaces it: terraform get downloads or updates modules only (never providers or the backend), and terraform providers lock writes provider checksums into .terraform.lock.hcl — for example for extra platforms — without installing any plugins into the working directory.
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 version selected for each provider and its checksums, so every teammate and every CI run installs identical provider builds. Unlike .terraform, the lock file should be committed to version control.
Once a provider is recorded in the lock file, plain terraform init installs that locked version — even if a newer release also satisfies the constraint. To move forward deliberately, run terraform init -upgrade: it re-selects the newest versions that the constraints allow and rewrites the lock file, which you then commit so teammates and CI get the same release. -upgrade never leaves the constraint: with version = "~> 5.0" it can pick the newest 5.x but never 6.0. It also upgrades modules to the newest versions their version arguments allow, not just providers.
The lock file and the constraint must agree. If you tighten a constraint so that it excludes the locked version — for example the lock holds 5.40.0 and you change the constraint to ">= 5.60" — plain init fails with an error saying the locked version does not match the configured constraint, until you run terraform init -upgrade to select a matching version and rewrite the lock.
The lock file covers providers only; module versions are never recorded in it. Repeatable module selection therefore comes from the version argument on the module block itself:
Module version | What a fresh init or init -upgrade installs |
|---|---|
"3.1.0" (exact) | Always exactly 3.1.0 — identical module code on every machine and run |
"~> 3.1" (pessimistic) | The newest 3.x at or above 3.1 available at that moment — may differ between runs as new releases are published |
no version | The newest published version |
The version argument applies to registry modules; Git sources pin with a ?ref= tag or commit instead.
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). A backend block cannot reference input variables, locals or TF_VAR_ environment variables, so values that differ per environment or must stay out of the code are supplied at init time instead, through partial configuration with -backend-config (key=value pairs or a file). -backend-config is an init option only; plan and apply do not accept it.
$ terraform init -backend-config="bucket=my-tf-state" -backend-config="region=eu-west-1" $ terraform init -backend-config=prod.s3.tfbackend
When you change the backend configuration, plain init detects the change and will not proceed until you state your intent. The choice depends on where the correct state lives:
| Flag | What it does | Use it when |
|---|---|---|
-migrate-state | Copies existing state from the previous backend to the new one, prompting for confirmation | The old backend holds the state and the new one is empty, and you want to keep managing the same resources |
-migrate-state -force-copy | Same migration, answering yes to the copy prompts — for non-interactive pipelines | As above, but in automation. It still overwrites any state already in the destination |
-reconfigure | Ignores the saved backend configuration and initializes against the new backend; copies nothing | The new backend already holds the correct state (for example a teammate migrated it), or you deliberately want to start from what the new backend contains |
Other init flags you should recognise:
-upgrade— upgrade providers and modules to the newest versions allowed by their constraints, updating the lock file.-backend-config=...— supply partial backend configuration at init time.-backend=false— skip backend initialization entirely; useful in CI jobs that have no state credentials and only need to runvalidate.
A typical case: you move state storage from local disk to an S3 bucket and want Terraform to keep managing the existing resources, so the state has to travel with you — that is terraform init -migrate-state. If instead the bucket already contains the up-to-date state, migrating would overwrite it with your local copy, which is why -reconfigure exists.
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. 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 a standard early 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": []
}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, missing credentials or missing IAM permissions — those surface at plan or apply time, when Terraform actually talks to the provider.
Three commands are often confused as CI gates:
| Command | Checks | Needs credentials or state? |
|---|---|---|
terraform fmt -check | Only whether files are in canonical formatting — not whether the configuration is valid | No (does not even need init) |
terraform validate | Syntax, argument names, references, types | No — needs init for provider schemas only |
terraform plan -refresh=false | Builds a full plan against stored state without refreshing | Yes — it still configures the providers and reads state from the backend |
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 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. Because it reads state, plan acquires the state lock by default on backends that support locking, so by default it fails immediately if another run holds the lock (the default -lock-timeout is 0s); setting -lock-timeout (for example -lock-timeout=5m) makes it wait and retry, and -lock=false disables locking (risky).
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).
Plan modes and options
-varand-var-fileset input variables.-refresh-onlyplans only the reconciliation of state with reality — it proposes updating state to match drifted objects and never proposes configuration changes.-destroypreviews a full teardown: it is the read-only preview of whatterraform destroy(an alias forterraform apply -destroy) would remove.-refresh=falseskips the refresh, which is faster on large configurations — but plan then compares the configuration against the stored state only, so changes made outside Terraform (drift) are invisible to it.-out=FILEsaves the plan (see below).
Exit codes in automation
By default a successful plan exits with 0 whether or not changes are pending; only an error produces a non-zero code. Options such as -json (machine-readable output) and -input=false (no interactive prompts) change the output or the prompting, not the exit status. To make the status reveal pending changes, add -detailed-exitcode:
| Exit code | Plain terraform plan | terraform plan -detailed-exitcode |
|---|---|---|
| 0 | Succeeded (with or without changes) | Succeeded, no changes |
| 1 | Error | Error |
| 2 | Not used | Succeeded, changes present |
That is the standard way a CI job detects drift or pending changes from the exit code alone.
Reading plan output: +, ~, -, and replacement
Every proposed action in plan output is marked with a symbol. + 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, with a note like "forces replacement" beside the offending argument. By default a replacement is shown as -/+: destroy the old object first, then create the new one. If the resource sets lifecycle { create_before_destroy = true }, the order flips and the plan shows +/-: create the replacement first, then destroy the old object. 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 (destroy then create) | Default replacement order when a change forces replacement |
+/- | Replace (create then destroy) | Replacement with create_before_destroy ordering |
<= | 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 in either order 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 — the plan is not recalculated, so nothing that changed in the configuration between review and execution can slip in. 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
Properties of a saved plan file:
- Binary format. Inspect it with
terraform show, not a text editor. - May contain sensitive values (including variable values and provider data), so store and pass it like a secret.
- Variables are embedded. Input variable values are captured in the plan, so you do not pass
-varagain at apply time. - No approval prompt. Applying a saved plan skips the interactive yes, because the review happened when the plan was generated.
- Stale plans are rejected. The plan is tied to the state it was created from; if the state has changed since (for example another apply ran), Terraform refuses to apply it and you must plan again.
Tip. Questions are workflow scenarios: which command comes next for a fresh clone, a new provider or module, a changed provider argument or a changed backend; how plain init, init -upgrade, the lock file and module version arguments decide which versions get installed; which backend flag (-migrate-state, -force-copy, -reconfigure, -backend-config, -backend=false) fits a situation; what validate checks compared with fmt -check and plan; what plan reads, locks and changes, its modes (-refresh-only, -destroy, -refresh=false) and exit codes with and without -detailed-exitcode; reading the +, ~, -, -/+, +/- and <= symbols and the summary counts; and the behaviour of saved plan files.
- 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 when providers, modules (source or version) or the backend change — not when a provider argument like region or a resource argument changes. It creates no infrastructure and, with the local backend, no state file.
- terraform get installs modules only; terraform providers lock updates the lock file without installing plugins. Neither replaces init.
- .terraform.lock.hcl pins provider versions and checksums and is committed; .terraform is not. Plain init reinstalls the locked version; init -upgrade picks the newest version within the constraints (never past them), upgrades modules too, and rewrites the lock file to commit.
- Modules are not in the lock file. For registry modules, only an exact version ("3.1.0") installs identical module code on every run; Git sources pin with ?ref= set to a tag or commit.
- A backend change needs -migrate-state (copy state; add -force-copy to skip prompts, which still overwrites the destination) or -reconfigure (new backend already holds the right state). -backend-config supplies partial configuration and is accepted by init only.
- terraform validate checks syntax and internal consistency offline after init (use init -backend=false in CI); fmt -check checks only formatting; plan -refresh=false still needs providers and state.
- terraform plan is read-only and holds the state lock; -refresh=false skips drift detection, -refresh-only reconciles state, -destroy previews a teardown. A plain plan exits 0 even with changes; -detailed-exitcode returns 2 when changes are pending.
- Plan symbols: + create, ~ update in-place, - destroy, -/+ replace destroy-first, +/- replace create-first (create_before_destroy), <= read; a replacement counts as one add plus one destroy.
- terraform plan -out=tfplan saves a binary, possibly sensitive plan; terraform apply tfplan executes exactly it without a prompt, with variables embedded, and rejects it if the state has changed since.
Frequently asked questions
When do I need to run terraform init again?
Run init again whenever the working directory's dependencies change: you add a provider requirement, add a module block or change a module's source or version, or add or modify the backend configuration. Terraform detects the mismatch and refuses to plan until you reinitialize. Changing a provider configuration argument such as region, or editing resources, needs no re-init. 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.
What is the difference between terraform init -migrate-state and terraform init -reconfigure?
Both are used after the backend configuration changes. terraform init -migrate-state copies the existing state from the previous backend into the new one (add -force-copy to answer the confirmation prompts automatically in a pipeline, which still overwrites any state in the destination). terraform init -reconfigure ignores the previous backend configuration and copies nothing, so it is the right choice when the new backend already holds the correct state.
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 within your constraints, run terraform init -upgrade and commit the rewritten lock file.
Does terraform plan return a non-zero exit code when there are changes?
Not by default. A successful terraform plan exits 0 whether or not changes are pending, and exits 1 on an error; options such as -json or -input=false do not change that. Add -detailed-exitcode to get 0 for no changes, 1 for an error and 2 for changes present, which is how automation detects drift or pending changes from the exit status.
Source
This lesson covers the "Core Terraform workflow" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.
- HashiCorp Terraform Associate certification page — HashiCorp
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.