Skip to content
intermediate Phase 8 · Provider Configuration and Version Pinning

Version Constraint Syntax

Writing pessimistic, range, and exact constraints for providers and modules.

50m
0 problems
Topic Progress 0%

Reading `~> 5.31.0`: What the Pessimistic Constraint Actually Allows

Ask three engineers what ~> 5.31.0 permits and you will get three answers. The operator — called the pessimistic constraint — has one rule: the rightmost component you wrote may increase; everything to its left is pinned, and the upper bound is one bump of that rightmost component.

Written Equivalent to Accepts from 5.31.0 onward
= 5.31.0 exactly that version 5.31.0 only
~> 5.31.0 >= 5.31.0, < 5.32.0 5.31.1, 5.31.9 — no 5.32.0
~> 5.31 >= 5.31.0, < 6.0.0 any 5.x at or above 5.31
~> 5 >= 5.0.0, < 6.0.0 any 5.x
>= 5.10, < 6.0 explicit range 5.10 … 5.99
>= 4.0 floor only 4.x, 5.x, 6.x…

The last two rows show the other half of the language. Comparison operators combined with a comma (read as AND) express a window directly, and a lone >= expresses "no ceiling at all". Wherever Terraform accepts a version constraint you get this whole vocabulary: the version argument in required_providers, the version argument on a registry module call, and required_version, which constrains the Terraform binary itself:

terraform {
  required_version = ">= 1.5.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.31.0"   # patches only: 5.31.x
    }
  }
}

Why would you ever choose each form? An exact pin (= 5.31.0) is for environments where every machine must resolve an identical binary — it is the statement you make when reproducibility beats convenience, and it pairs with the lock file described in Provider Lock File Integrity. A tight pessimistic constraint (~> 5.31.0) lets security patches land without inviting a minor-version upgrade. A loose one (~> 5.31) follows the 5.x line and stops before the breaking major. A range is how you say "we support these minors, tested from 5.10 up". And a floor (>= 5.10) is how reusable modules talk, because a library should not dictate the ceiling for the application that embeds it.

The syntax rules underneath the table are small but worth committing to memory:

  • Constraints are strings, so they are always quoted in HCL: version = "~> 5.31.0". An unquoted value is a different expression entirely.
  • A comma joins constraints with AND. ">= 5.10, < 6.0" is one window, not two alternatives.
  • There is no OR. You cannot write "5.31.x or 6.0"; incompatible acceptance has to be expressed by relaxing a comparison, because init must be able to compute one intersection across every module.
  • Versions compare as sequences of numbers, not text: 1.10 is newer than 1.9, and 5.31.0 sits above 5.30.9. String-sorting versions is the bug that makes people think a constraint excluded a release it actually accepts.

One warning about precision: ~> is only as tight as the number of components you typed. Writing ~> 5.31 when you meant patch-only gives up the minor ceiling entirely, and this single character is the difference between "we accept 5.31.4" and "we accept 5.90.0". Interviewers like this question because it exposes whether you have actually read an upgrade diff you did not want.

Pre-releases are the other trap. 5.31.0-beta1 is skipped entirely unless your constraint itself names a pre-release, so a beta you installed by hand on Friday will not be selected by anyone else's terraform init on Monday.

One Provider, Many Constraints: How init Finds the Version Everyone Accepts

A configuration is a conversation between authors you will never meet. Your root declares AWS one way, every module you call declares it another way, and terraform init must find a single version that satisfies all of them simultaneously.

Consider a realistic tree:

# root — main.tf
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = ">= 5.10, < 6.0" }
  }
}

module "storage" {
  source = "./modules/storage"
  # child requires aws: "~> 5.31.0"
}

module "network" {
  source = "./modules/network"
  # child requires aws: ">= 5.0"
}

Init intersects the three requirements: >= 5.10, < 6.0 ∩ >= 5.31.0, < 5.32.0 ∩ >= 5.0 = 5.31.x at or above 5.10, and it installs the newest version in that window. Nobody has to agree in advance; only the intersection matters.

When the intersection is empty, init refuses to guess:

Error: Failed to determine selected provider requirements

  hashicorp/aws: version constraint "~> 4.0" required by module.legacy
  conflicts with root constraint ">= 5.10, < 6.0"

(Real output names the provider, both constraints, and the module address that introduced the losing one.) The fix is never to delete a constraint blindly — it is to decide which caller is out of date and update that module, because the constraint you remove is a compatibility promise someone was relying on.

Resolving an intersection failure is a short, repeatable investigation:

terraform providers      # see every constraint and which module declared it
terraform init           # retry: init re-reads the configuration each run

Then edit the losing side — bump the stale module to a newer release, or, if the root's ceiling is the obstacle, widen it deliberately — and re-run init until the Finding line shows a range with at least one available version. The winning constraint is not "the strictest one"; it is whatever survives the intersection, and a strict constraint in an abandoned module can block upgrades you are otherwise entitled to make.

One asymmetry worth knowing: this whole machinery applies to providers, not to module versions. Terraform records provider selections in .terraform.lock.hcl but has no lock file for remote modules — every init re-picks the newest module version satisfying the module call's version argument. That is why a registry module should also be pinned exactly (version = "5.7.0") if you need byte-for-byte reproducibility, and why "we pinned the module" is only true when you pinned the version argument yourself.

You can inspect the merged view without touching a cloud account:

$ terraform providers
Providers required by configuration:
.
├── provider[registry.terraform.io/hashicorp/aws] >= 5.10, < 6.0
├── provider[registry.terraform.io/hashicorp/random] ~> 3.6
└── module.storage
    └── provider[registry.terraform.io/hashicorp/aws] ~> 5.31.0

Providers required by state:
    provider[registry.terraform.io/hashicorp/aws] 5.31.0

The top tree is what the configuration asks for — constraints, per module, with the root at .. The bottom list is what the state was last written by: exact versions, because state records reality rather than intent. Comparing the two answers the classic interview question "your plan shows no changes, so why did init just install a new provider?" — constraints decide what may be installed, and the lock file decides what is installed.

Two workflow details round out the mechanism:

  • terraform init with no flags re-selects the newest available version satisfying every constraint, but only when no lock file entry exists. Once a selection is recorded, init keeps it; terraform init -upgrade is what discards it and re-picks.
  • Constraints are read only by init. plan, apply, and validate never re-resolve a version, so a constraint edit that nobody followed with terraform init changes nothing at runtime.

That split — constraints express intent, the lock file records the decision — is why the next topic matters as much as this one: Provider Lock File Integrity.

Constraints That Look Safe but Freeze or Flood Your Upgrades

A constraint is a promise you make to every future reader of the repository. These four shapes break that promise in opposite directions.

The library that dictates its users' upgrades. HashiCorp's guidance is blunt: a module meant to be reused should declare a floor (>= 5.10), and the root module should own the ceiling (~> 5.31). Put ~> 4.0 inside a shared module and every consumer is blocked at 4.x until you release a new version of that module — teams then fork your module or pin a stale tag, which is worse than the incompatibility you were guarding against. State known incompatibilities in the module README instead of in code.

The root with no ceiling. version = ">= 4.0" in a root module means terraform init -upgrade may hand you a major version nobody tested, on the day the provider ships it. Reusable modules get floors; roots get floors and ceilings.

The window that excludes the version you need. Range constraints are easy to get wrong in review: ">= 5.0, < 5.31.1" looks generous but rejects 5.32 outright, and ">= 5.10, <= 5.31" uses an inclusive bound where you almost certainly meant ~> 5.31.0. Write the range out in both forms before committing — ~> 5.31.0 and >= 5.31.0, < 5.32.0 should be recognizably identical.

Editing a constraint and expecting a new version. Constraints only describe the search space. If .terraform.lock.hcl already records 5.31.0, changing ~> 5.31.0 to ~> 5.32.0 and running a plan changes nothing, because plan never re-resolves. You must run terraform init (which will notice the recorded selection no longer satisfies the constraint) or, when you deliberately want the newest permitted version, terraform init -upgrade:

$ terraform init -upgrade
Upgrading plugins...
- Installing hashicorp/aws v5.32.1...

That -upgrade run is the moment the lock file changes, and the diff of that file is the review artifact your teammates should see — Provider Lock File Integrity explains what the hashes mean and why you never delete it to "fix" an init error.

The upgrade nobody planned. A root module holding only a floor means the first init -upgrade — or a teammate initializing a fresh clone after you widened a module's requirement — can cross a major version in one step. Provider majors routinely remove deprecated arguments, change how counts or tags behave, and alter resource schemas, so the surprise does not present as "a new version installed" but as a plan full of replacements you did not intend to author that morning. The ceiling in a root constraint is the only gate that runs before the diff, which is exactly why it lives there.

Before you approve a constraint change, five questions settle it:

  1. Is the change in the right layer — a floor in the reusable module, floor plus ceiling in the root?
  2. Does the new window still exclude pre-releases you do not want (5.31.0-beta1)?
  3. Did someone run terraform init and read the Finding line to confirm the intersection is non-empty?
  4. Is the lock-file diff a single version move with rewritten hashes, rather than a regenerated file?
  5. Do all targeted platforms still carry hashes — terraform providers lock -platform=... if a platform was added?

One last distinction interviewers probe: required_version constrains the Terraform CLI, not a provider, and it is enforced before anything else is resolved. A configuration with required_version = ">= 1.5.0" fails on a 1.4 binary with a message about the Terraform version, never a message about providers — knowing which layer rejected you is half of debugging init.

For the declaration layer these constraints hang off, revisit Declaring Required Providers.

Quiz

1. Which provider versions satisfy `version = "~> 5.31.0"`?

Question 1 options

2. The root requires aws `">= 5.10, < 6.0"` while a called module requires `"~> 4.0"`. What does terraform init do?

Question 2 options

3. You are publishing a reusable module that other teams will call. What should its provider `version` argument be?

Question 3 options

4. Which constraint is equivalent to `~> 1.2`?

Question 4 options

Flashcards

Question

Expand `~> 5.31.0` into an explicit range.

Answer

`>= 5.31.0, < 5.32.0` — the rightmost typed component may increase, so this accepts patch releases only. `~> 5.31` would instead mean `>= 5.31.0, < 6.0.0`.

Question

Expand `~> 5.31` and `~> 5`, and state what each allows.

Answer

`~> 5.31` = `>= 5.31.0, < 6.0.0` (any 5.x at or above 5.31); `~> 5` = `>= 5.0.0, < 6.0.0` (any 5.x). The ceiling is always one bump of the rightmost component you wrote.

Question

Where can version constraints be written in HCL?

Answer

In `required_providers { version = ... }` for providers, in the `version` argument of a registry module call, and in `terraform.required_version` for the Terraform CLI binary itself — which is checked before any provider resolution happens.

Question

What constraint style belongs in a reusable module versus a root module?

Answer

Reusable modules declare a floor (`>= 5.10`) so consumers are not forced into simultaneous upgrades; root modules declare a floor and a ceiling (`~> 5.31.0` or a range) so an upgrade never happens accidentally.

Question

You changed a version constraint and reran plan — the provider did not change. Why?

Answer

Constraints only define the search space; plan and apply never re-resolve versions, they use what init installed and what the lock file records. Run `terraform init`, or `terraform init -upgrade` when you deliberately want the newest permitted version.

Revision Notes

Key Takeaways

  • 1. `~> 5.31.0` means `>= 5.31.0, < 5.32.0` — only the rightmost typed component may increase, so component count sets the ceiling
  • 2. Comma-separated comparisons form a range, and a lone `>=` deliberately leaves the ceiling open
  • 3. Constraints are intersected across the root and every module; when the intersection is empty init fails rather than picking a side
  • 4. Reusable modules declare floors only, while root modules own the ceiling so upgrades arrive as reviewed diffs
  • 5. Constraints define the search space; the lock file records the decision, and only init (plus `-upgrade`) re-selects

Interview Tips

  • • "What does `~> 5.31.0` allow?" — give the expansion `>= 5.31.0, < 5.32.0`, then contrast it with `~> 5.31`; the follow-up is almost always that comparison
  • • "Two of your modules disagree on a provider version — what do you do?" — show you know init intersects rather than overrides, then locate the outdated module and change only its constraint
  • • "How do you pin provider versions for the whole team?" — constraints bound the search, `.terraform.lock.hcl` pins the exact binary, and `terraform init -upgrade` is the only sanctioned way to move the pin
  • • "Where would you use an exact `= ` constraint?" — in a root that must be bit-reproducible or during incident bisects; add that reusable modules should stay at a floor

Cheat Sheet

Version Constraint Syntax

The operator table:

Written Equivalent to Typical use
= 5.31.0 exactly 5.31.0 hard reproducibility
~> 5.31.0 >= 5.31.0, < 5.32.0 patches only
~> 5.31 >= 5.31.0, < 6.0.0 follow the 5.x line
~> 5 >= 5.0.0, < 6.0.0 any 5.x
>= 5.10, < 6.0 explicit range (comma = AND) tested window
>= 5.10 floor only reusable modules

Rule of thumb: ~> lets only the rightmost component you typed increase; the upper bound is one bump of it. The more components you write, the tighter the promise — ~> 5.31.0 vs ~> 5.31 is the classic review catch.

Where constraints live:

terraform {
  required_version = ">= 1.5.0, < 2.0.0"      # Terraform CLI
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.31.0" }
  }
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"                            # registry module
}

Selection semantics:

  • init intersects every constraint from the root and all child modules; empty intersection = init fails and names both requirements
  • with no lock entry, init picks the newest version that satisfies the intersection
  • a recorded lock entry wins until terraform init -upgrade (or an entry that no longer satisfies the constraints)
  • pre-releases (5.31.0-beta1) are skipped unless the constraint names one

Continue Learning