DEV Community

Cover image for What version constraints actually look like across real Terraform estates
Daniel Westgaard
Daniel Westgaard

Posted on • Edited on • Originally published at riftmap.dev

What version constraints actually look like across real Terraform estates

I parsed the public HCL of four Terraform module estates and bucketed how every module block pins its version. How a module version is written turns out to be an organisational habit, not a per-module choice.

I expected the Azure Verified Modules to be a ~> world. I was wrong, and the way I was wrong is the point of this post.

I mirrored twelve of the AVM pattern modules into a throwaway org and parsed how their examples/ pin the modules they consume. The unit I am counting is a module block that pins a registry module version: a module block whose source points at a registry and which carries a version attribute. Relative source = "../../" blocks have no version, so they are out. Git sources carry their version in the URL, and they get their own section later. Of AVM's 134 such pins, 113 were bare exact pins like version = "0.10.2" (84%), and only 21 used the pessimistic ~> operator (16%). I had assumed the opposite, because I associate registry-style module consumption with ~> ranges rather than exact pins, and AVM is about as registry-native as Terraform code gets.

Being wrong about one ecosystem made me want to check the wider question. If my read on how AVM pins versions was that far off, what do version constraints actually look like across real Terraform estates? So I widened the parse. The answer was not a percentage. It was a pattern.

Across the four public Terraform estates I parsed, how a module version is written is decided by the organisation that owns the repository, not by the module itself. Within a single org's repositories the style is close to uniform. It changes between orgs, not within them.

The version string is an organisation's habit, not a module's

Each estate I parsed has one dominant way of pinning versions, and it does not mix. I read every module block's source and version straight from the HCL across three public module estates plus the AVM mirror, then bucketed each registry version pin by shape. The estates sort cleanly into two camps.

cloudposse pins bare exact versions. Of its 999 module blocks that pin a registry version, 997 are bare exact pins with no operator, and the other two are exact as well, one carrying a v prefix and one a prerelease tag. That is 99.8% bare, and effectively all of them exact. Not one uses ~>, and not one leaves the version unbounded. The convention is as close to absolute as real code gets.

The HashiCorp-curated collections do the opposite. terraform-aws-modules and terraform-google-modules float with ~>. terraform-aws-modules puts 297 of its 307 registry pins on ~> (96.7%); the other ten are eight bare exact pins, one exact pin with a v prefix, and a single unbounded >= floor. terraform-google-modules is 432 of 445 on ~> (97.1%), with three exact pins and ten unbounded floors, a shape like >= 3.15 with no ceiling above it. Effectively all ~>, but not literally all. I come back to those unbounded floors later, because that shape is the one that actually bites. The ~> strings themselves mix two-part and three-part arities freely, ~> 0.6 next to ~> 0.16.3, which matters more than it looks. But the convention is not in doubt.

The AVM examples sit in the same camp as cloudposse: 84% exact pins (113 of 134), with ~> as the only other meaningful shape.

# cloudposse, AVM examples: bare exact pins, estate-wide
version = "0.1.0"
version = "0.10.2"
version = "0.12.0"

# terraform-aws-modules, terraform-google-modules: ~>, estate-wide
version = "~> 0.6"
version = "~> 0.4.0"
version = "~> 0.16.3"
Enter fullscreen mode Exit fullscreen mode

So four estates fall into two conventions, and no estate straddles. cloudposse and the AVM examples pin exact. The curated AWS and GCP collections float with ~>. Whether a given repository pins or floats is almost entirely predicted by which org it lives in, not by anything about the module itself. Versioning style is a house convention, set once and applied estate-wide.

A blended percentage would be worse than no number

Pooling these estates into one figure produces a number that mostly measures which org has the most module blocks, not how the world pins versions. Take the four estates as one pool: 1,885 module blocks that pin a registry version. Close to 60% are exact pins and close to 40% use ~>, with the eleven unbounded floors the small remainder. It looks like a fact about Terraform. It is not. cloudposse alone is 999 of those 1,885 blocks, 53% of the pool, and cloudposse pins exact. So the pooled "mostly exact" is largely a restatement of "cloudposse is over half the rows." Change the mix of orgs in the sample and the "global" number moves, even though nothing about the world changed.

This is why the finding is reported per-org and never pooled. The honest unit here is the estate, not the module, because the estate is where the decision is actually made. Any single headline percentage across a handful of orgs is really a statement about which estates happened to be in the sample, weighted by their block count. It travels badly, and a careful reader knows it.

What I parsed, and what it cannot tell you

Every estate in this sample is a module publisher, and that is a real limit on what the numbers mean. cloudposse, the terraform-aws and terraform-google collections, and the AVM modules all publish modules for other people to use. The version strings I measured live mostly in their own examples/ directories, which is how a module pins itself and its siblings for reproducible CI. That is not the same as how a downstream enterprise estate consumes dozens of third-party modules it did not write.

The AVM number carries this caveat most sharply. Those examples are authored by the AVM team and pinned exact precisely so their CI is reproducible. They almost certainly over-represent exact pins relative to how a real enterprise estate looks, consuming many third-party modules under looser and more mixed discipline. So read the 84% as "this is how AVM authors pin in their own examples", not "this is how enterprises consume AVM".

What the sample does support is the structural claim, because that claim is about homogeneity within an estate, and every estate I looked at was internally consistent. Whether real consumer estates are as tidy as publisher estates is a question this data cannot answer. I would expect them to be messier. I would still expect the dominant-convention pattern to hold, because the convention is set by whoever writes the org's module standards, and most orgs have exactly one such standard.

The shape of the constraint decides how safely a tool can target a module

Once you point an automated tool at module versions across an org, the constraint shape stops being cosmetic and starts deciding what is safe to touch. This is the part that matters if you build or run anything that rewrites module versions in bulk, whether that is an OpenRewrite recipe, a codemod, Renovate-style automation, or your own reasoning about what a bump will break.

An exact pin is the easy case. version = "0.10.2" says exactly one thing, and a tool can match it, compare it, and rewrite it without guessing.

A ~> range is harder but tractable. ~> 0.16.3 means >= 0.16.3, < 0.17.0, and ~> 0.6 means >= 0.6.0, < 1.0.0, because the operator lets only the rightmost component increment (that is Terraform's own definition). Two-part and three-part forms expand differently, and the curated collections use both, so a tool that handles ~> at all has to get that arity right or it will silently mis-scope which versions count as a match.

The case that actually bites is the unbounded one, and it is worth being precise about where the danger lives. Picture the naive version of a version matcher: one that only understands exact pins. When it meets a ~> or a >= block it has two options. It can skip the block, which is safe. Or it can strip the operator and treat ~> 0.16.3 as if the module were pinned to 0.16.3, which is not safe, because it is silent. An unbounded >= 0.3.0 collapsed to a 0.3.0 pin quietly matches a module whose author deliberately left the version open to float upward. The module might be running 2.x in practice. A migration aimed at the 0.3 line then rewrites a block it should never have touched, and the diff looks clean. In a tool that rewrites code across repositories, a silent false positive is the worst failure mode there is, because nothing tells you it happened.

That is the abstract hazard, and the honest thing is to measure the real tooling against it rather than assume the worst. The recipe most relevant to this work has two parts that are easy to run together and worth keeping apart: the comparison that decides whether a concrete module version satisfies a recipe's constraint, and the step that reads the module's own declared version into a concrete version in the first place. Paweł Oczadły has been building OpenRewrite HCL recipes for exactly this class of Terraform rewrite, and the comparison half is right. I ran it against a full table of cases. Given a concrete recipe constraint and a module version, it decides match or no-match with the correct Terraform semantics, 27 of 27. It reads ~> 0.10.0 as >= 0.10.0, < 0.11.0 and ~> 0.10 as >= 0.10.0, < 1.0.0, upper bound exclusive, two-part and three-part arities kept distinct, bare versions treated as equality rather than a floor. The hard part, the semantics, is done, and done well.

The open edge is in the reading, not the comparing, and it is on unbounded versions. A bounded ~> 0.16.3 normalises cleanly to 0.16.3, a reasonable floor to match on. An unbounded >= 0.3.0 is the case the hazard above described, a version its author left open to float, read as if it were pinned at the floor. Paweł has an open issue to close exactly that, so unbounded module-side declarations return no-match rather than a collapsed pin. An engineer tightening a known boundary in the open, which is the useful thing to know before you trust a rewrite to it.

Unbounded >= floors are rare in what I parsed: eleven of the 1,885 registry pins, all in the two ~> estates and none at all in cloudposse or the AVM examples. But rarity is not safety. The shape is uncommon and also precisely the one that is hardest to handle correctly, which is a good reason to design for it before it turns up rather than after. Getting that boundary right, bounded ranges normalised and unbounded ranges treated honestly as not-yet-evaluated rather than guessed, is what separates a matcher that is safe on an estate from one that can occasionally rewrite the wrong block without saying so.

The connection back to the distribution is direct. Because convention is set per-org, coverage is all-or-nothing per estate. A tool that handles only exact pins cleanly targets every cloudposse repo and every AVM example, and misses effectively the whole terraform-aws and terraform-google world, not module by module but wholesale, because those estates float with ~>. Whether it works on your estate is decided by your org's house style, not by the individual module you are trying to migrate.

Git-sourced modules are a third shape again

A git:: source carries its version in the URL, not in a version attribute, so it needs completely separate handling. Git sources are the tail, not the body, of what these estates use. In cloudposse they are around 2% of module blocks, roughly two dozen out of some 1,451. The bulk of the non-pin remainder is not git at all. It is relative source = "../../" blocks inside examples/, which point at the module under test and carry no version. Git still matters, because it is a genuinely distinct third shape, and it does not read at all if you only look at the version attribute.

Those refs come in two shapes that want different treatment. cloudposse writes semver tags behind a tags/ prefix, like git::https://github.com/cloudposse/terraform-aws-ecr.git?ref=tags/0.16.0. The version is in there, but a parser has to strip the tags/ prefix before it can read 0.16.0 and apply the same constraint logic. The AVM mirror instead had ?ref=main, a branch name with no version in it at all. One is recoverable to a version, the other is not, and a tool that wants to reason about git-sourced modules has to tell them apart. Either way, none of this surfaces if you only read the version attribute, because for a git source that attribute is usually absent.

The real question is about the estate, not the module

The instinct, when you evaluate any cross-repo version tool, is to ask "does it handle ~>". After parsing these estates I think that is the wrong question. The right one is "does this estate's house convention happen to be one the tool handles", because the convention is set at the organisation level and applied almost uniformly beneath it. You can predict, from the org alone, whether a version-rewriting migration will land cleanly or silently misfire, before you run anything against a single repository.

That is a different kind of knowledge from a per-file check. It is the map of the estate rather than a scan of one repo. Know which convention an org uses and you know, in advance, which of your repositories a module migration will actually reach, and which it will quietly skip or wrongly rewrite.

This is the layer Riftmap is built on, and I want to be precise about what it does today rather than wave at what it might. The numbers in this post come from parsing the public HCL directly: the module source and version attributes, read deterministically, the same constructs Riftmap's parser reads. Parsed, not inferred. Nothing here is guessed from names or text.

For cloudposse, that is not just the method, it is the live product. Point Riftmap at the cloudposse org and its resolved graph already carries this exact shape: 359 Terraform module edges, 302 of them bare exact pins and almost all the rest the tags/-pinned git refs from the section above, with zero ~> and zero unbounded floors. That is why the graph's exact-pin share sits near 84% rather than the 99.8% of the registry pins alone, it counts the git-sourced modules in the same denominator, but the convention underneath is identical. The convention this post describes is sitting in the graph, queryable, right now. That is the reproducible proof.

For the module-publisher estates the honest picture is narrower, and worth saying plainly. Riftmap parses and stores every one of these constraints today. It recorded on the order of 915 module declarations for terraform-aws-modules and roughly 3,400 for terraform-google-modules, each with its version constraint attached. What it does not yet do is resolve those public-registry refs into the org-level blast-radius graph, or surface the per-org ~> distribution as a product view. So the aws and google distributions in this post are parsed, not yet surfaced. Surfacing them org-wide is on the roadmap, and it is exactly the kind of thing the parser is built for. Where it does not yet, I would rather say so than imply a product view that does not exist.

The point that survives all of this is the one the whole post is about. The same parse that tells you an org's convention tells you which repositories consume which module at which version, which is the blast radius of a version bump before you make it. The dependency that used to live only in the head of the engineer who set the module standard becomes something you can query.

Questions engineers actually ask

Do Terraform modules usually pin exact versions or use ~>?
It depends almost entirely on the organisation, not the module. In four public estates I parsed, each org was internally near-uniform: cloudposse pins bare exact versions (99.8% of its registry module blocks) and the Azure Verified Modules examples do the same (84%), while the curated terraform-aws-modules and terraform-google-modules collections use ~> for around 97% of theirs, with only a handful of exact pins and a few unbounded >= floors. Whether a repository pins or floats is predicted by which org it belongs to, not by anything about the module.

How do I find every consumer of a Terraform module across repositories?
Parse the source and version attributes of every module block across your whole organisation, rather than grepping repo by repo. That gives you the full consumer graph: which repositories reference a module, at which version constraint, and therefore what a version bump would reach. Riftmap does this across an entire GitHub or GitLab org from one read-only token.

What version constraint should I use for a Terraform module?
Use an exact pin (version = "0.10.2") when you want maximum reproducibility and to upgrade only deliberately, and a pessimistic constraint (~> 0.10) when you want controlled patch or minor upgrades without editing the constraint each time. Avoid an unbounded >= with no upper bound in production, since it lets a module float across a major version without warning. Terraform's version constraint syntax documents each operator.

Can you rewrite a Terraform module version across an org automatically?
Yes, in two halves. The apply half is a deterministic rewrite, for example an OpenRewrite HCL recipe that changes a module's version and any associated blocks. The discovery half is knowing which repositories to run it against, which is a cross-repo parse of who consumes the module and at what constraint. The rewrite is only ever as safe as the targeting underneath it.

About Riftmap

Riftmap maps cross-repo dependencies across your entire GitLab or GitHub organisation — Terraform, Docker, CI templates, Helm, and more. One read-only token. No YAML to maintain.

Top comments (0)