Chapter 6: Modules
Topic 39

Versioning and Version Constraints

VersioningModules

Version constraints control which versions of providers and modules Terraform will use — ~> 6.0, >= 1.2, < 2.0. They are the difference between a reproducible build and one that drifts every time upstream ships a release. The pessimistic operator ~> and the dependency lock file together give you the goal: new patches automatically, breaking changes never by surprise.

Unconstrained dependencies are a slow-motion outage. A config with no version pins works today, then a teammate runs a fresh init next month, pulls a new major version, and inherits breaking changes nobody chose. Constraints turn that random event into a deliberate, reviewed upgrade.

Constraint Syntax

A constraint is one or more comparison rules. = 6.1.0 pins an exact version. >=, <=, >, and < set bounds, and you combine them with commas: >= 6.1.0, < 7.0.0 accepts anything in that range. The most common operator is ~>, the pessimistic constraint, which encodes "this minor line, no further." Each rule narrows the set of acceptable versions; Terraform picks the newest version that satisfies all of them.

The Pessimistic Operator

The ~> operator allows the rightmost component to increase and nothing above it. ~> 6.0 allows any 6.x — 6.1, 6.7, 6.42 — but not 7.0. ~> 6.1.0 is tighter: it allows 6.1.x only, so 6.1.3 is in but 6.2.0 is out. That one extra digit is the difference between "accept all minor and patch updates in the 6 line" and "accept patch updates to 6.1 only," and misreading it is a common way to get more or less than you intended.

Reading the pessimistic operator
~> 6.0
Allows any 6.x — 6.1, 6.7, 6.42 — but never 7.0. The whole 6 minor line.
~> 6.1.0
Tighter: allows 6.1.x only — 6.1.3 is in, 6.2.0 is out. Patch updates to 6.1.
Provider and module constraints with the pessimistic operator
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"    # any 6.x, never 7.0
    }
  }
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.1.0"          # 6.1.x only
  name    = "prod"
}

Semantic Versioning

The ~> operator leans on semantic versioning, the MAJOR.MINOR.PATCH contract. A major bump signals breaking changes — removed inputs, renamed outputs, changed behavior. A minor bump adds features backward-compatibly. A patch is a bug fix with no interface change. ~> 6.0 works because SemVer promises 6.x will not break what 6.0 did; the major boundary at 7.0 is where breaking changes are allowed, and that is exactly the line the constraint refuses to cross.

Provider and Module Constraints

There are two places constraints live, and they look slightly different. Provider constraints go in the required_providers block, as a version alongside the source. Module constraints go in the module block as a version argument — but only for Registry-sourced modules. A git or local-path module has no version argument; you pin a git module with ?ref= on a tag instead. Same goal, two mechanisms, depending on where the dependency comes from.

Upgrading Deliberately

terraform init -upgrade re-resolves dependencies to the newest versions your constraints allow and rewrites the lock file. Within a ~> 6.0 constraint that pulls the latest 6.x safely. Crossing a major boundary is different work: raise the constraint to ~> 7.0, read the upgrade guide for the renamed inputs and outputs, run a plan, and review the diff — all as one reviewed change, never a blind bump in a hurry.

Common Mistakes
  • Leaving a provider or module version unconstrained, so a fresh init on another machine pulls a new major version with breaking changes nobody chose.
  • Pinning everything to an exact = version and never patching, accumulating known security and bug fixes you have explicitly locked yourself out of.
  • Misreading ~> 6.1 (allows all of 6.x) versus ~> 6.1.0 (allows 6.1.x only), and getting a far wider or narrower update window than intended.
  • Bumping a major version blindly with init -upgrade without reading the upgrade guide, applying renamed inputs and restructured outputs straight into production.
  • Not committing the dependency lock file, so the team and CI resolve different provider versions and plans disagree between machines.
Best Practices
  • Use ~> to allow safe patch and minor updates while blocking the next major version, on both providers and Registry modules.
  • Commit the dependency lock file so the whole team and CI resolve identical provider versions.
  • Treat every version bump as a reviewed change: read the changelog, raise the constraint, run a plan, review the diff.
  • Pin git modules to an immutable tag with ?ref= and upgrade them deliberately, never via a moving branch or wildcard.
  • Cross a major boundary as a dedicated upgrade — read the upgrade guide first — rather than folding it into an unrelated change.
Comparable tools npm / pip version ranges are the close analogy Pulumi pins provider and package versions through its language package manager CloudFormation has no equivalent — AWS manages resource behavior

Knowledge Check

What does ~> 6.0 allow versus ~> 6.1.0?

  • ~> 6.0 allows any 6.x but not 7.0; ~> 6.1.0 allows 6.1.x only
  • Both allow exactly the same range of versions — the extra patch digit is simply ignored
  • ~> 6.0 allows only 6.0 exactly, while ~> 6.1.0 allows any 6.x release
  • Both allow versions up to and including 7.0 but exclude 7.1 and later

What is the trade-off of pinning every dependency to an exact = version?

  • It is perfectly reproducible but locks you out of patch fixes, accumulating known security and bug debt
  • Terraform refuses an exact pin and requires a range operator like ~> instead
  • An exact pin disables the dependency lock file from being written
  • An exact pin still floats up to the latest available patch on each init anyway, so in practice it changes nothing

Why does the ~> operator rely on semantic versioning?

  • SemVer promises that minor and patch releases are backward-compatible, so ~> 6.0 can accept 6.x safely and block 7.0 where breaks are allowed
  • SemVer guarantees that every published version is fully compatible with every other version, so the operator can accept any of them without risk
  • Without a SemVer scheme in place, Terraform has no way to download providers from the registry
  • The operator orders releases by their publication date, which the SemVer scheme records

Why commit the dependency lock file?

  • So the team and CI resolve identical provider versions and plans agree across machines
  • Because Terraform refuses to run any command unless the lock file is checked into the repo
  • It stores the actual provider binaries so they need never be downloaded again
  • It replaces the need to write any version constraints in the configuration

You got correct