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.