SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Terraform state and backend operations

Terraform Professional: Dependency Chains and Version Upgrades

18 min readTerraform Pro 002 · Terraform state and backend operationsUpdated

A Terraform version upgrade is the move from one Terraform CLI release to a later one across every environment that runs a configuration, and its dependency chain is everything that must accept the new release first: each module's required_version, the providers and their stored schemas, saved plans, pipelines and the other configurations that read your state. This lesson covers how to map that chain with read-only commands, write version constraints that do not block consumers, pin exact releases in CI, roll an upgrade from dev to prod, and roll back safely when HashiCorp does not guarantee a downgrade.

What you’ll learn
  • Explain what required_version, the dependency lock file and terraform init -upgrade each control, and why only installing a new binary changes the Terraform release
  • Read version constraints precisely, including ~> component rules, comma AND-ing and pre-release matching, and apply the module-minimum versus root-bounded guidance
  • Map an upgrade's dependency chain with terraform providers, terraform modules -json, terraform graph, terraform version -json and the state's terraform_version field
  • Identify hidden and cross-configuration dependencies: depends_on for run-time dependencies, module-level depends_on side effects, terraform_remote_state readers and output renames
  • Pin exact Terraform releases in pipelines so saved plans apply, and keep module and provider selections reviewed
  • Roll an upgrade through dev, stage and prod one change at a time, and roll back core or provider upgrades from a pre-upgrade state snapshot

What does upgrading Terraform actually change?

Upgrading Terraform means running a different Terraform CLI binary against the same configuration and state. Nothing inside a configuration installs or selects the CLI: you change the version by installing a new binary on laptops and build agents, or by changing the pinned release in each pipeline. Several settings sound as if they control the version, but each does something narrower.

Setting or commandWhat it actually doesDoes it change the CLI that runs?
Installing a new binary (setup step, image tag, package)Puts a different Terraform release on the pathYes, this is the upgrade
required_version in a terraform blockChecks the running CLI against a constraint and stops if it does not matchNo, it only refuses an unsupported binary
.terraform.lock.hclRecords the selected provider versions and checksumsNo, it never mentions the CLI or modules (and required_providers cannot constrain the CLI either)
terraform init -upgradeRe-selects providers and modules within their constraintsNo
terraform providers lockWrites provider checksums for chosen platformsNo

A Terraform upgrade therefore touches three things in a fixed order of concern: the dependency chain (every module, provider and other configuration that has to accept the new release), the rollout (which environment runs which binary and when) and the state (which the new release rewrites the first time it applies, and which older releases are not guaranteed to read).

How does required_version work across the module tree?

required_version is a version constraint on the Terraform CLI that every module in the configuration may declare, and the running Terraform must satisfy all of them: the root module's, each child module's, nested modules' and registry modules'. If any constraint is not met, Terraform prints an unsupported Terraform Core version error naming the module and exits before taking any action. It does not warn and continue, it does not download another release, and the root's looser constraint never overrides a stricter one in a child.

The check runs on every command that loads the configuration, including init, plan and apply. That makes a pinned module anywhere in the tree a blocker for a core upgrade. When a registry module at a fixed version declares, say, ~> 1.11.0, the fix is to move the module call to a newer release whose constraint admits the new CLI (and review that release's changes). Editing the cached copy under .terraform/modules is unsupported and is overwritten by the next init; removing the root's required_version does nothing to the child's; and init -upgrade can only choose module versions the call's version argument already allows.

How do the version constraint operators behave?

ConstraintMeansAdmits 1.13.4?
~> 1.12.0>= 1.12.0 and < 1.13.0 (only the patch may grow)No
~> 1.12>= 1.12 and < 2.0 (the minor may grow)Yes
>= 1.11Any release from 1.11 upwardYes
>= 1.12.0, < 1.14.0Both conditions (commas mean AND)Yes
= 1.13.0 or 1.13.0Exactly that releaseNo

The pessimistic operator ~> lets only the right-most component you wrote increase, so the number of components decides how much it allows. A team that meant "patches only" but wrote ~> 1.12 has allowed every later 1.x release.

Pre-release versions (anything with a dash suffix, such as 1.14.0-rc2) are matched only by an exact constraint, written with = or no operator. >, >=, <, <= and ~> all skip them, and because comma-separated constraints are combined with AND, adding an exact beta to an existing range would require every run to be that beta. Trialling a pre-release in one environment therefore needs a separate configuration or branch for the trial.

Which version constraint should a module declare, and which should a root declare?

Reusable modules should declare only the minimum Terraform version they need, with >=; root modules, which decide what actually runs, may bound both ends, typically with ~>. That is HashiCorp's guidance for version constraints, and it exists because of the module rule above: an upper bound or exact pin in a shared module blocks every consumer from upgrading Terraform until the module publishes a new release.

Shared / reusable moduleRoot module
Typical formrequired_version = ">= 1.11.0"required_version = "~> 1.12.0"
PurposeState the oldest release whose features the module usesControl which releases may run this configuration
Risk of an upper boundBlocks consumers' core upgradesIntended: a new minor needs a reviewed change

How do language features set a module's minimum version?

Every language feature has a release that introduced it, and a module that uses the feature should declare that release as its minimum. Consumers on older Terraform then get a clear unsupported-version error pointing at the module instead of confusing errors about unknown blocks or arguments. When a module uses several features, the minimum is the latest of their introduction releases. A provider upgrade never changes a core requirement, and neither required_providers nor the lock file can express a Terraform core version.

FeatureIntroduced in Terraform
moved blocks1.1
import blocks1.5
removed blocks1.7
Provider-defined functions (provider::aws::arn_parse)1.8
Ephemeral resources; the terraform modules command1.10
Write-only arguments (for example password_wo)1.11 (and a resource that supports them)

Which commands reveal the dependency chain before an upgrade?

Five read-only sources together map an upgrade's dependency chain, and each answers exactly one question. Mixing them up is the most common reason an upgrade inventory is wrong.

Command or sourceAnswersDoes not tell you
terraform providersThe provider requirement tree: which module declares which provider constraintModule versions or resource ordering
terraform modules -json (1.10+)Every module call in the configuration, nested calls included, with its key, source and version, in stable JSONProvider constraints
terraform graphA DOT graph of how resource and data blocks depend on each other (render with Graphviz); -type=plan and others show operation graphsVersions of anything
terraform version -jsonThe CLI release that ran the command and the provider versions selected for this working directory (provider_selections)What wrote the remote state, or what a pipeline runs
terraform state pullThe raw state snapshot; its top-level terraform_version field is the release that last wrote itConstraints

When terraform init reports that no provider version satisfies all constraints after you raise a root constraint, terraform providers pinpoints the module still asking for an older release. To find which of many environments' states were last written by an old release, pull each state and read terraform_version; the same field name in terraform version -json describes only your local binary. Lock files and required_version describe constraints and selections, never history. Neither terraform state list nor terraform modules shows which object depends on which.

What dependencies does terraform graph not show?

terraform graph shows only the edges Terraform knows about, which come from expression references and depends_on. A dependency that exists only at run time has no edge. For example, a virtual machine whose startup script connects to a database may rely on a firewall rule defined as its own resource; if the VM references only its network and not the rule, Terraform could create or change the rule after the VM, and the graph you use for upgrade planning will not show the VM downstream of the rule.

depends_on is the documented tool for exactly this hidden dependency: add it to the dependent resource naming the resource it relies on, and the edge then appears in the graph. replace_triggered_by changes when a resource is replaced, not ordering, and -target is a one-off workaround that leaves the next full apply exposed.

Why prefer references over depends_on?

HashiCorp recommends depends_on only as a last resort, because it is coarse. On a module block, depends_on = [module.dns] makes Terraform finish every action in the DNS module, including reads, before processing any object in the dependent module. Whenever anything in the DNS module has a pending change, the dependent module's data sources are deferred to apply time, their values are unknown during plan, and arguments built from them can force replacements. If the dependent module only needs the DNS zone's ID, pass that output in as an input instead; the module is then tied only to the values it uses. lifecycle { ignore_changes } would only hide the symptom.

How do other configurations join the upgrade chain through remote state?

A configuration that reads another's state or outputs is part of that configuration's upgrade chain, even though nothing in either repository references the other's code. A terraform_remote_state data source reads the producer's latest state snapshot using the consumer's own Terraform release, and it exposes only the producer's root module outputs.

HashiCorp's v1.x compatibility promises guarantee that newer releases can read state written by older ones, not the reverse. So when a producer such as a shared database configuration is upgraded, the safe ordering is to find every configuration that reads its state and upgrade those readers no later than the producer: every read then stays in the guaranteed direction. The producer's own modules and providers are dependencies too, but not of this cross-configuration read.

How do you rename an output that other teams read?

Treat a root output that consumers read as a public interface. As soon as the producer applies a change that removes db_endpoint, every consumer still reading that name fails on its next plan, so a rename that relies on consumers changing first, or on running every consumer in lockstep, breaks teams that apply on their own schedule. Add the new output (primary_db_endpoint) alongside the old one, keep both until every consumer has switched, then remove the old one. moved blocks apply to resources and module calls, not outputs.

How do providers and modules move during an upgrade?

Providers move only when the dependency lock file changes, while modules move whenever they are installed, because .terraform.lock.hcl records provider selections and checksums only. That difference decides how each part of the chain is controlled.

  • Providers: once a provider is in the lock file, a plain terraform init always re-selects the recorded version. If you raise a constraint outside the recorded selection (for example an azurerm constraint from ~> 3.110 to ~> 4.0), plain init fails; run terraform init -upgrade, which picks the newest version the constraints allow and rewrites the version and hashes, then commit the reviewed lock file diff. The constraints field inside the lock file is informational, deleting .terraform leaves the lock file in place, and -reconfigure concerns the backend.
  • Modules: Terraform selects the newest release that meets the module call's version constraint each time it installs modules, which on fresh CI runners is every run. A call such as version = "~> 5.1" silently picks up 5.6.0 when it is published. To make module upgrades happen only through reviewed changes, set the call to an exact release and bump it deliberately.

With no configuration changes, terraform init -upgrade can change the lock file's selections and the provider and module releases downloaded into .terraform. It cannot change the Terraform binary, any .tf file, or the terraform_version recorded in state, because init -upgrade does not rewrite state.

Why does an older provider refuse state written by a newer one?

Providers version their resource schemas and upgrade each stored object when they write it. After an apply with a newer provider (even one that only updated tags), the state holds instances in the newer schema, and an older provider meeting them refuses with an error that the resource instance is managed by a newer provider version. This catches colleagues whose branch still has the old lock file as well as rollbacks. Reverting the lock file alone cannot fix it; you either run the newer provider or restore a state snapshot taken before the newer apply (see the rollback section). -refresh=false does not avoid loading the instances, and terraform state replace-provider changes a provider's source address, not its version.

How do you keep every pipeline on one exact Terraform release?

You keep a pipeline on one release by having the pipeline itself install an exact, versioned Terraform release (a setup-step version, a pinned container image tag such as hashicorp/terraform:1.12.2, or a version file read by the setup step) that lives in the repository, so changing it is a reviewed pull request. Runners that use whatever is preinstalled, or the latest image tag, drift as soon as a new release ships.

The alternatives do not work: an exact required_version only turns drift into failures because it installs nothing, the lock file covers providers only, and init -upgrade moves providers and modules rather than the CLI.

Why must plan and apply jobs match exactly?

A saved plan file can be applied only by the same Terraform version that created it, because the binary plan format is internal and changes between releases. A plan made on 1.12.2 and applied by 1.12.1, or a plan made on latest before a release and applied after it, fails before changing anything. The plan does not expire, and no flag fixes it; re-planning in the apply job would discard the plan that was approved.

A saved plan also records the dependency lock selections. If the apply job runs terraform init -upgrade and picks up a provider release published since the plan, apply stops with an inconsistent dependency lock file error: the plan was created with a different set of external dependency selections. Both jobs should run plain terraform init against the committed lock file. (A different error, "Saved plan is stale", appears only when the state changed after planning.)

How do you roll a Terraform upgrade across dev, stage and prod?

Roll a Terraform upgrade through environments in order, dev then stage then prod, changing one thing at a time and confirming that each environment shows no unexpected changes on the new release before promoting it. The v1.0 compatibility promises say you can upgrade from any v1.x release directly to any later v1.x release, so you do not need to apply each intermediate minor release; instead read every changelog between the two releases for behaviour changes that affect you, then verify in dev first.

  • Separate the core upgrade from provider upgrades. Two upgrades in one change make any new diff impossible to attribute.
  • Preview safely. terraform plan refreshes in memory but does not persist state, so running a plan with the new release, even against prod's workspace, leaves the stored snapshot untouched. CLI workspaces keep their state in the configured backend, so an apply from a laptop on prod's workspace would rewrite the shared prod state.
  • Keep refresh on. Planning with -refresh=false hides drift and the differences you are trying to see.

How can one configuration run two releases during the rollout?

When one root configuration on one branch serves several environments, its required_version is shared by all of them. During the rollout it must admit both releases, for example >= 1.12.0, < 1.14.0, while each environment's pipeline pins its own exact binary: dev on 1.13, stage and prod on 1.12 until their soak time ends. Tighten the constraint to the new minor's patch range once prod has moved. Changing the constraint to the new release alone would break stage and prod; widening it without pinning binaries leaves each runner to decide.

Upgrading Terraform versions in HCP Terraform workspaces uses workspace settings and is covered in the HCP Terraform domain's lesson on workspace upgrades.

How do you roll back a Terraform or provider upgrade?

The supported way back from a Terraform or provider upgrade is to restore a state snapshot taken before the new version first wrote state, then run the old version. Downgrading Terraform might work but is not guaranteed, because later releases may change how state is stored; the state's terraform_version records which release wrote it.

  1. Before the first apply on the new release, save the state: terraform state pull > pre-upgrade.tfstate. This matters most when the backend keeps no object versions. A saved plan, provider tree or lock file is not a copy of state.
  2. To roll back, put the old binary (or, for a provider, the old lock file plus terraform init) back in place and restore the snapshot with terraform state push. The old snapshot has a lower serial than the current one, so the push needs -force.
  3. Plan with the old version. If the upgrade apply changed no real infrastructure, or only changes that refresh reconciles (such as tags), the restored snapshot still matches reality and the plan shows nothing unexpected.

What does not work: running the old release against state the new one wrote, running init -upgrade to "convert" state (init -upgrade does not rewrite state), or a plan-and-apply with the old release, which still has to read the newer state first.

Tip. Expect production scenarios with a stated constraint: no long-lived branches, independent consumer schedules, keep a module on a supported release, least ongoing effort, or avoid behaviour HashiCorp does not guarantee. Items present a version constraint or a terraform block and ask whether a given CLI release runs, which constraint a module or root should declare, or what minimum a set of language features requires. Others give an inventory goal and ask which command or state field answers it, a pipeline failure involving saved plans, lock selections or image tags and ask for the cause, or a failed upgrade and ask for the supported rollback. Several are multiple-response and some distractors use the same field name or a real command that answers a different question.

Key takeaways
  • Only installing a different binary changes the Terraform release; required_version checks it, the lock file tracks providers only, and init -upgrade moves providers and modules.
  • The running CLI must satisfy every required_version in the module tree; an unmet one, even in a nested or registry module, stops Terraform before it acts.
  • ~> lets only the right-most stated component grow: ~> 1.12.0 means patches only, ~> 1.12 allows any later 1.x. Pre-releases match only exact constraints.
  • Reusable modules declare a minimum (>=) at the latest feature they use; root modules bound both ends.
  • terraform providers shows which module declares each provider constraint, terraform modules -json lists every module call, terraform graph shows ordering, and state pull's terraform_version shows what last wrote state.
  • terraform graph only knows references and depends_on; add depends_on for run-time-only dependencies, but prefer references over module-level depends_on.
  • Upgrade terraform_remote_state readers no later than the producer, and publish renamed outputs alongside the old name until consumers switch.
  • A saved plan applies only with the same Terraform release and the same lock selections, so pin one exact release for plan and apply and use plain init in both.
  • Roll dev to stage to prod, core and providers in separate changes; plan never persists state, and a direct jump between v1.x releases is supported.
  • Downgrades are not guaranteed: snapshot state before the first apply and roll back by restoring it (state push -force) with the old version.

Frequently asked questions

Does required_version install the Terraform version it names?

No. required_version only compares the running Terraform CLI against a constraint and stops with an error if it does not match. To change the version that runs, install a different binary, for example by pinning an exact release in each pipeline's Terraform setup step or container image tag.

What is the difference between ~> 1.12 and ~> 1.12.0 in required_version?

The pessimistic operator lets only the right-most component you wrote increase. ~> 1.12.0 means at least 1.12.0 and below 1.13.0, so only patch releases are allowed. ~> 1.12 means at least 1.12 and below 2.0, so every later 1.x minor release, such as 1.13.4, is allowed.

Should a shared Terraform module pin an exact Terraform version?

No. HashiCorp recommends that reusable modules declare only the minimum Terraform version they need, using >=, because Terraform enforces every module's required_version and an upper bound in a shared module would block all of its consumers from upgrading. Root modules are where upper bounds belong.

Can I downgrade Terraform after applying with a newer version?

Not reliably. Terraform's v1.x compatibility promises cover upgrades from any v1.x release to a later one, but downgrades might not work because newer releases may change how state is stored. The supported way back is to restore a state snapshot taken before the newer release first applied, then run the older release.

Why does terraform apply refuse a saved plan made in another pipeline job?

A saved plan can be applied only by the same Terraform version that created it, and it also records the provider selections from the dependency lock file. If the apply job runs a different Terraform release, or runs terraform init -upgrade and selects a newer provider, apply stops before making changes. Pin one exact release for both jobs and use plain terraform init.

Does terraform init -upgrade upgrade Terraform itself?

No. terraform init -upgrade ignores the provider versions recorded in the dependency lock file and installs the newest provider and module releases that the configuration's constraints allow, updating the lock file. It never changes the Terraform CLI, the .tf files or the state.

How do I find which module blocks a provider or Terraform upgrade?

For provider conflicts, run terraform providers, which prints the provider requirement tree showing which module declares each constraint. For a Terraform core conflict, the unsupported version error names the module whose required_version fails; terraform modules -json (Terraform 1.10 and later) lists every module call with its source and version so you can check release notes.

Is it safe to run terraform plan with a new Terraform version against production?

Yes, for the state. terraform plan refreshes objects in memory but does not persist the refreshed state, so the production snapshot keeps the version that last wrote it. Only an apply (or another state-writing command) with the new version rewrites the shared state.

Source

This lesson covers the "Terraform state and backend operations" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?