A shared Terraform module should constrain providers with a minimum only, such as >= 5.0, while the root module that calls it sets both a lower and an upper bound with ~>. The module declares what it needs. The root module decides what actually runs.
What HashiCorp recommends
Two HashiCorp pages say the same thing. The module providers page says that for shared modules you should constrain only the minimum required provider version using a >= constraint, set to the earliest version that has the features the module uses, so callers can select newer versions.
The version constraints page adds the other half. Reusable modules should constrain only their minimum Terraform and provider versions. Root modules should use ~> to set both a lower and an upper bound for each provider they depend on.
Why the split makes sense
Think of constraints as negotiation. Every module in a configuration states a range it can work with, and Terraform must find one provider version that satisfies all of them. If a shared module sets a tight upper bound, it can make two modules impossible to use together, because their ranges no longer overlap.
The root module is different. It is the place where the whole configuration is assembled and tested, so it can afford to be strict. The lock file then records the exact selection. See what the Terraform lock file protects.
The operators in one table
| Operator | Meaning | Good home |
|---|---|---|
>= 5.0 | Any version from 5.0 upward | Shared module |
~> 5.1 | Allows 5.x from 5.1 up, not 6.0 | Root module |
~> 5.1.4 | Allows 5.1.5 and 5.1.10, not 5.2.0 | Root module, tighter |
= 5.1.4 or 5.1.4 | Exactly one version | Rare, breaks easily |
!= 5.1.3 | Excludes a known bad version | Either, with a comment |
For ~>, HashiCorp explains that only the rightmost version component may increment.
Pre-release caution
Terraform does not match pre-release versions on >, >=, <, <= or ~> operators. A pre-release is only selected when named exactly. So a >= floor will not pull in a beta by accident.
How to set the floor honestly
- List the provider features the module uses, such as a specific argument.
- Find the release that introduced each one in the provider's changelog.
- Take the latest of those releases as the floor.
- Record the reason in a comment next to the constraint.
- Revisit the floor when the module starts to use a newer argument.
A floor that is too low causes confusing errors for callers on old versions. A floor that is too high forces needless upgrades.
Reviewing a module change
When a pull request touches required_providers, ask:
- Did the floor rise? Callers on older providers will fail at init, so release notes should say so.
- Was an upper bound added? That conflicts with the guidance above and needs a strong reason.
- Is the source address unchanged? A changed namespace is a different provider, and the pull request should explain it. See required_providers source addresses.
Where Safeguard fits
Safeguard's SBOM and dependency scanning covers the dependencies your repositories declare, and a gate can require review when manifests change. Provider version choices still need the human judgement described here.
Quick check
Should a shared module ever pin an exact provider version?
Rarely. An exact pin leaves callers no room to upgrade and can conflict with other modules. Pin exact versions in the root module through the lock file instead.
Does the root module need an upper bound?
HashiCorp recommends ~> in root modules so each provider has both a lower and an upper bound.
Sources
Last reviewed: 19 October 2026.