Why pin versions
Without explicit constraints, terraform downloads "the latest," which means that six months from now a colleague might be on a different major version and get a different plan from the same code. That breaks determinism, the main point of IaC.
So in HCL you write two constraints:
terraform {required_version = ">= 1.5, < 2.0"
required_providers { aws = {source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
required_versionis which versions of the terraform tool itself may run this code.required_providers.<name>.versionis which versions of the provider.
Both constraints are checked when any command starts (init, plan,
apply). If something does not match, terraform refuses to run and prints
a clear message.
Operators
| Form | What it means | When to use it |
|---|---|---|
1.9.5 or = 1.9.5 | exact match | rarely, only when a specific version has a bug |
>= 1.5 | no lower than | e.g. for required_version, open upper bound |
<= 5.60 | no higher than | rarely |
~> 5.60 | 5.60.x, 5.61.x, ..., but not 6.x | the standard for providers |
~> 5.60.0 | only 5.60.x | very strict, usually overkill |
>= 1.5, < 2.0 | compound | the standard for required_version |
!= 5.55.0 | exclude | rarely, for a known bad version |
Several constraints joined by a comma act as a logical AND.
The pessimistic constraint ~>
This is the most common operator. ~> X.Y means "X.Y or newer, but no
higher than X+1.0."
Concretely:
~> 5.60becomes>= 5.60, < 6.0. 5.60.x, 5.61.x, and 5.99.999 all fit; 6.0.0 does not.~> 5.60.0becomes>= 5.60.0, < 5.61.0. Only patches inside 5.60 fit.
For providers the standard is ~> X.Y. It allows bug fixes and new
resources while protecting you from major releases with breaking changes.
For required_version the standard is >= X.Y, < X+1. The terraform tool
follows SemVer more strictly than providers do, so you set the upper bound
at the next major.
required_version: what it controls
- It blocks a run on a version that is too old (it lacks the features your HCL relies on).
- It blocks a run on a version that is too new (when you set an upper bound).
Good practice is to set >= X.Y, < X+1.0, where X.Y is the version the code
was actually written against. Avoid an open upper bound (>= 1.5 with no
ceiling): that lets terraform 2.x into the project with potentially breaking
changes.
Where version constants live between runs
- The
required_*blocks live in.tffiles; you write them by hand. - The exact installed versions live in the [[tf-lockfile|lockfile]]
(
.terraform.lock.hcl). - The provider binary cache lives in
.terraform/providers/.
The lockfile pins the exact version within the constraint. The constraint
defines the range terraform searches for a new version on init -upgrade.
How to move to a new major version
Say the code has aws ~> 5.60 and the AWS provider has released 6.0:
- Read the changelog between 5.x and 6.0. It lists the breaking changes.
- Adjust the HCL for the new attribute and resource names.
- Change the code to
version = "~> 6.0". - Run
terraform init -upgrade; the lockfile updates. - Run
terraform planand check that the diff matches what you expect (only the planned changes, no surprises). - Only then run
apply.
Never do step 4 without step 1.
Pitfalls
- An open upper bound is a landmine.
>= 5.0with no ceiling will let a major version through automatically. Six months later this turns into an urgent hotfix on a Friday. ~> X.Ywith two numeric segments is not the same as~> X.Y.Z. It is easy to mix up. Remember a simple rule: the dot to the right of~>is "freedom inside," the left is "upper bound."required_versionis sometimes forgotten. Without it the code runs on any terraform, including a badly outdated one. At a minimum, writerequired_version = ">= 1.5".required_providerswithoutversionis also valid. It means "any version." Do not do this; it throws away determinism right away.required_providersgoes inside theterraform {}block. Theversionfield insideprovider "..." {}is deprecated. It shows up often in old code; move it up. See tf-provider-block.