Chapter 7: Modules
Topic 45

Versioning and Version Constraints

VersioningModules

A registry module without a pinned version is a reproducibility hole. terraform init resolves it to whatever the latest published version happens to be, so the same configuration applied a month apart can fetch different module code and produce a different plan — with no change of yours to explain it. You pin module versions exactly the way you pin providers: the pessimistic constraint ~> 9.0 on the version argument.

The discipline is the same one you learned for the google provider, applied to modules. Pin tightly enough that a breaking major release cannot land by surprise, loosely enough that safe patches arrive without a manual bump, and read the module's CHANGELOG before you raise the floor. Get this right and "new patches automatically, breaking changes never by surprise" becomes the default rather than a hope.

The version Argument

Only registry sources take a version argument — the same restriction from the sourcing topic. version = "~> 9.0" on terraform-google-modules/network/google allows any 9.x release but not 10.0, which is the pessimistic operator you already use for providers. The constraint lives on the module block, separate from the source, and Terraform resolves it at init against the versions published to the Registry.

Pin a CFT module exactly as you pin a provider
module "network" {
  source  = "terraform-google-modules/network/google"
  version = "~> 9.0"   # allows 9.x, blocks 10.0 — safe patches in, breaking majors out
  project_id   = var.project_id
  network_name = "hatch-vpc"
}

Why Unpinned Breaks Reproducibility

With no constraint, init pulls the newest published version every time it resolves. Your laptop ran init last week and got 9.2; CI runs it fresh today and gets 9.4; next month a 10.0 lands and the next clean checkout fetches that. Three machines, three module versions, three potentially different plans — and not one line of your configuration changed. Reproducibility is the property that the same config produces the same plan, and an unpinned module quietly destroys it.

The Pessimistic Operator

The operator that does the work is ~>, and its exact reach depends on how many version components you write. ~> 9.0 allows 9.x but not 10.0 — minor and patch updates flow in, the major is locked. ~> 9.1.0 is tighter: it allows 9.1.x only, so 9.2 is blocked too. Misreading these is a common error — people write ~> 9.1 expecting 9.1.x and get all of 9.x, or write ~> 9.1.0 expecting 9.x and get only patches. SemVer is what makes the operator meaningful: major means breaking, minor means features, patch means fixes, and ~> leans on modules honoring that.

CFT Modules Ship Majors

The terraform-google-modules modules version independently and cut real major releases that rename inputs or change resource behavior — the network module's 8.x-to-9.x jump renamed variables and reshaped outputs. An unpinned bump can rename a variable out from under you mid-sprint, and a plan that suddenly wants to destroy and recreate your VPC is the result. Pinning with ~> is what keeps that major release a deliberate, reviewed upgrade instead of an ambush on a Tuesday init.

Reading the CHANGELOG Before Bumping

Every CFT module keeps a CHANGELOG.md, and a major version's entry lists exactly which inputs were removed or renamed and which behaviors changed. Reading it before you raise the constraint is the difference between a clean upgrade and a surprised apply — the failure mode is debugging an apply error that the CHANGELOG documented as a removed input. Bump one module at a time, on a branch, so a plan diff maps to a single CHANGELOG entry rather than a tangle of simultaneous bumps you cannot attribute.

Git-sourced modules pin differently but follow the same rule. A git source has no version; you pin it with ?ref=v1.2.0 as the sourcing topic showed, so the mechanism differs by source type while the discipline — always an immutable pin — does not. Putting a version on a git source is a silent no-op: it is ignored, and the module floats on whatever ref you gave.

~> 9.0 vs ~> 9.1.0

~> 9.0 — allows every 9.x release (9.1, 9.2, 9.9) but blocks 10.0. The common choice: take all minor and patch updates within a major, lock out the breaking major. Use it for a module you want to keep current without surprises.

~> 9.1.0 — allows 9.1.x only (9.1.1, 9.1.2) and blocks 9.2. The tight choice: take patches but not new minor features. Use it when even a minor release has burned you and you want patches only until you review the next minor deliberately.

Common Mistakes
  • Calling a CFT registry module with no version and getting a different module version on the next init that changes the plan with no config change of yours.
  • Pinning loosely with >= 9.0 instead of pessimistically with ~> 9.0, so a 10.0 major with renamed inputs lands on the next init -upgrade.
  • Bumping a module major version without reading its CHANGELOG, then debugging an apply failure that the CHANGELOG documented as a removed input.
  • Putting a version on a git-sourced module and assuming it pins anything — it is ignored, and the module floats on whatever ref you gave.
  • Misreading ~> 9.1 (allows all 9.x) versus ~> 9.1.0 (allows 9.1.x only) and getting more or fewer updates than you intended.
Best Practices
  • Pin every registry module with a pessimistic constraint (~> 9.0), exactly as you pin the google provider.
  • Read the module's CHANGELOG.md before bumping a major version, and do the bump on a branch with a full plan review.
  • Pin git-sourced modules to an immutable tag (?ref=v1.2.0), keeping the same "always pinned" discipline across source types.
  • Upgrade one module version at a time so a plan diff maps to a single CHANGELOG, not a tangle of simultaneous bumps.
  • Commit the dependency lock file so providers resolve identically across the team, and treat every version bump as a reviewed change.
Comparable tools npm · pip · Go language version ranges and lockfiles as the mental model Helm chart version pinning in a dependency block Pulumi package version constraints

Knowledge Check

What does the constraint ~> 9.0 on a CFT module allow?

  • Any 9.x release but not 10.0 — minor and patch updates in, the breaking major locked out
  • Only version 9.0.0 exactly, accepting no minor or patch releases at all
  • Any version 9.0 or higher, including 10.0 and every later major beyond it
  • Only the 9.0.x patch line, blocking any automatic move up to 9.1 or to any later minor release

Why does an unpinned registry module break reproducibility?

  • Each init resolves to the latest published version, so different machines or dates fetch different code and plans
  • Terraform outright refuses to run any registry module without a version constraint, halting the whole build at init time
  • The module's output value formats silently change every single time it is re-downloaded
  • Unpinned modules are always installed into the wrong cache directory on each machine

You put version = "~> 1.2" on a git-sourced module. What happens?

  • It is ignored — git sources pin with ?ref=, so the module floats on whatever ref you gave
  • Terraform converts the constraint into a matching git tag ref automatically at init
  • It pins the module to the 1.2.x version range, behaving exactly like a registry source would
  • Terraform errors at init and refuses to proceed until you remove it

Before bumping a CFT module across a major version, what should you do?

  • Read the module's CHANGELOG for removed or renamed inputs, then bump on a branch with a full plan review
  • Run init -upgrade and apply straight to production immediately, to catch any problems fast
  • Bump every module in the config at once, so the plan reflects all of the changes together
  • Delete the dependency lock file so Terraform re-resolves every module and provider version cleanly from scratch

You got correct