A Terraform module looks like a function: it takes input variables, produces outputs, and encapsulates reusable logic. That comparison breaks down on a structural point that often surprises: a module doesn’t have its own state, it shares the root module’s state, the one that calls it.
What “no state of its own” actually means
A module called multiple times from the root module creates distinct resources, but all tracked in the same state file as the rest of the configuration. There’s no state isolation between a module and its caller: a terraform state list from the root module shows every resource, including those created by nested modules, prefixed by their call path.
module "vpc_prod" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
}
module "vpc_staging" {
source = "./modules/vpc"
cidr = "10.1.0.0/16"
}
# Both instances show up in the same state,
# prefixed by the module path that created them
terraform state list
# module.vpc_prod.aws_vpc.main
# module.vpc_staging.aws_vpc.main
Why this matters for an apply’s blast radius
Since everything shares the same state, a terraform apply on the root configuration evaluates and can modify every resource across every called module, not just the one just modified. A change in a module used by ten other configurations never stays contained to where the change was made: it propagates to every caller at the next apply, a blast radius that grows with the number of callers, not with the size of the change itself.
The trap of over-splitting
A module per individual resource (one module for an S3 bucket, another for an IAM policy attached to it) looks modular, but creates an implicit dependency between plans: the IAM policy module needs the bucket’s ARN, produced by another module, forcing either data passing between modules on every call, or coupling via data sources that recreate a hidden dependency Terraform has to resolve correctly on every plan.
# Over-split: every call has to manually pass what
# a broader module would handle internally
module "bucket" {
source = "./modules/s3-bucket"
}
module "bucket_policy" {
source = "./modules/iam-policy"
bucket_arn = module.bucket.arn
}
A module grouped by coherent functional unit (a “secure storage” module including the bucket and its policy) avoids that explicit inter-module dependency, at the cost of less fine-grained modularity if the bucket ever needs to exist without its policy.
Versioning, the real discipline often missing
A module referenced by a local path (./modules/vpc) has no version: any change to the module immediately affects every caller at the next apply, with no intermediate validation step. A module referenced from a registry or a Git repo with an explicit tag lets you pin a known version and evolve it deliberately, caller by caller.
# Pinned version: the module only changes if
# someone explicitly bumps this number
module "vpc" {
source = "app.terraform.io/example-org/vpc/aws"
version = "5.2.0"
}
Without this versioning discipline, a module shared across several teams becomes a fragile coupling point: nobody knows for certain which version is actually running where, until a change in the module breaks a caller that changed nothing on its own side.
Takeaway
A Terraform module doesn’t have its own state: it shares the root module’s, which means an apply always evaluates every resource across every called module, not just the one that just changed. Over-splitting into modules creates implicit dependencies between plans; an unversioned module (local path) propagates its changes immediately to every caller with no validation step. Both habits (grouping by coherent functional unit, explicitly versioning a shared module) avoid the fragile coupling that shows up the moment a CI/CD industrialization grows the number of Terraform configurations sharing the same modules.