A "Cycle" error means two Terraform resources depend on each other; here is how to find the loop and break it cleanly.
You run terraform plan, and instead of a plan you get a wall of text that starts with Error: Cycle: followed by a list of resource addresses. No line number, no obvious typo, nothing you can grep for in a single file. This one confuses people because the error is about relationships between resources, not the resources themselves.
Here is what is actually happening and how to get out of it.
Before Terraform applies anything, it builds a dependency graph. Every resource, data source, module, and output becomes a node. Every reference (aws_subnet.main.id, module.network.vpc_id, an explicit depends_on) becomes a directed edge. Terraform then walks that graph in dependency order so it creates things before the things that need them.
That only works if the graph is a directed acyclic graph: no node can, by following edges, eventually reach itself. A "Cycle" error means it can. Resource A depends on B, and B depends on A, either directly or through a chain of other resources. Terraform cannot decide which one to create first, so it refuses to plan.
The classic version comes from two security groups that reference each other:
Error: Cycle: aws_security_group.a, aws_security_group.b
Longer chains show the full loop, which is your best clue:
Error: Cycle: module.app.aws_iam_role.this,
module.app.aws_iam_role_policy.this,
aws_iam_role.this
Read the list as "each of these depends on the next, and the last one loops back to the first."
When the loop runs through more than two resources, generate the graph and look at it:
terraform graph | dot -Tsvg > graph.svg
terraform graph emits the dependency graph in DOT format; piping it through Graphviz's dot gives you an image. If you do not have Graphviz, even the raw text output shows the edges as "a" -> "b" lines that you can trace by hand. Follow the arrows from any name in the error message and you will find the path that returns to where you started.
A handful of patterns produce almost every cycle you will hit in practice.
Two security groups referencing each other. Group A allows traffic from group B, and group B allows traffic from group A, both with inline rules. Each SG now depends on the other's ID.
A resource referencing its own output. An attribute that feeds back into the same resource's arguments, often introduced by a for_each or count expression that reads from the thing it is building.
Module input/output loops. Module X takes an input from module Y's output, and module Y takes an input from module X's output. The loop is between the module boundaries, not any single resource.
depends_on creating a loop. Someone added an explicit depends_on to be safe, not realizing the target already depends on this resource. Now the edge goes both ways.
IAM role and policy mutual references. A role that references a policy which references the role's ARN back, common when an assume-role policy and an inline policy get tangled.
Here is the cyclic version. Two groups, each with an inline rule pointing at the other:
resource "aws_security_group" "a" {
name = "app-a"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.b.id]
}
}
resource "aws_security_group" "b" {
name = "app-b"
vpc_id = var.vpc_id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.a.id]
}
}
Group a needs b.id to exist, and group b needs a.id. Neither can be created first. Error: Cycle: aws_security_group.a, aws_security_group.b.
The fix is to stop putting the rules inside the groups. Define the security groups with no inline rules, then attach the rules as separate resources. Now each rule depends on both groups, and the groups depend on nothing but the VPC:
resource "aws_security_group" "a" {
name = "app-a"
vpc_id = var.vpc_id
}
resource "aws_security_group" "b" {
name = "app-b"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "a_from_b" {
security_group_id = aws_security_group.a.id
referenced_security_group_id = aws_security_group.b.id
ip_protocol = "tcp"
from_port = 443
to_port = 443
}
resource "aws_vpc_security_group_ingress_rule" "b_from_a" {
security_group_id = aws_security_group.b.id
referenced_security_group_id = aws_security_group.a.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
The graph now flows one way: both groups get created, then both rules attach. No node can reach itself. The older aws_security_group_rule resource works the same way if you are not on a provider version that has the newer aws_vpc_security_group_ingress_rule. The principle is identical: move the relationship out of the resource and into a resource that depends on both ends.
One caveat: do not mix inline ingress/egress blocks with standalone rule resources on the same security group. The provider fights itself and will drift on every apply. Pick one style per group.
Remove unnecessary depends_on. If resource B already references an attribute of A, delete any explicit depends_on = [A]. The implicit edge is enough, and the explicit one is often what closed the loop.
Restructure module boundaries. When two modules feed each other, one of them is doing too much. Pull the shared resource up to the root module, or into a third module both can depend on, so the dependency runs in one direction.
Split a resource. If a resource references its own output, separate the part that produces the value from the part that consumes it, the same move as the security group rules. Sometimes a data source reading the created resource, referenced later, avoids the self-reference entirely.
After any of these, run terraform graph again and confirm the loop is gone before you re-plan.
For the wider set of failures you will meet, see the Terraform error troubleshooting guide.
Reach for separate rule resources by default. Inline security group rules read nicely in a demo, but the moment two groups need to talk to each other you are one edit away from a cycle. Standalone aws_vpc_security_group_ingress_rule resources cost a few extra lines and make your dependency graph honest: groups first, relationships second. When a cycle does show up, do not guess. Run terraform graph, trace the arrows back to the start, and cut the single edge that should never have pointed backward.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A practical look at when to reach for count, when for_each saves you, and the specific errors each meta-argument tends to throw.
A practical guide to resolving Terraform provider version conflicts and lock file checksum errors across modules, developers, and CI pipelines.
Explore more articles in this category
Terraform's errors are scarier than the fixes. This is the map to the ones everyone hits: what each message means, the safe way out, and how to avoid losing state.
A practical guide to renaming resources, migrating backends, and splitting or merging Terraform state without destroying and recreating infrastructure.
A practical guide to resolving Terraform provider version conflicts and lock file checksum errors across modules, developers, and CI pipelines.
Evergreen posts worth revisiting.