Terraform Dependency Errors, All Three Kinds: Cycle, depends_on Misuse, known after apply
Terraform builds a dependency graph from resource references and executes in that order. The problems that come out of this graph sort into three families: Error: Cycle, where the graph loops and no order exists; depends_on misuse, where humans over-declare dependencies; and the known-after-apply family, where the graph is fine but a count cannot be fixed because its value is not yet computed. The error messages differ but the root is shared, so this post covers them together. The base grammar of dependencies and iteration is Basics #6.
Error: Cycle: when the graph loops #
When the error message says Cycle and lists resources, that list forms a loop of mutual references. Terraform cannot order a cyclic graph, so it stops at plan.
The classic practical case is mutually referencing security groups. A web SG whose inbound rule references the app SG, and an app SG whose outbound rule references the web SG, written as inline rules (ingress and egress blocks), makes two resources that each need the other first. The fix is pulling the rules out into separate resources: create both SGs bare, then let separate resources like aws_vpc_security_group_ingress_rule reference both. The graph becomes one-directional — SGs, then rules — and the cycle disappears. Designing SG chains with separate rule resources from the start is exactly what Practice #3, EC2 and security groups does; the Cycle error is what meeting that principle’s absence looks like.
Module-level cycles have the same structure: module A’s inputs take module B’s outputs and vice versa, and the whole modules become the loop. The fix is the module edition of rule extraction — hoist the shared values both sides need into a third resource created earlier (or the parent stack) and let A and B both reference that. For large graphs that resist reading, terraform graph | dot -Tsvg > graph.svg makes the loop visible, but most cases yield to reading the cycle list in the error top to bottom, tracing who references whom and why.
depends_on: implicit is the default, declaring is the exception #
Terraform’s dependencies come from references automatically. The moment you write subnet_id = aws_subnet.main.id, subnet-before-instance is settled, no declaration needed. depends_on is the exception tool for hidden dependencies that no reference expresses. The canonical legitimate case: a resource that must wait for an IAM policy attachment to finish before using those permissions — no data flows, but time orders.
The problem is the just-in-case habit. Overuse has two side effects. First, the graph serializes more than necessary, shrinking parallelism and slowing plan and apply. Second, and more quietly, changes to a depends_on target can force recomputation of whatever declared it, so diffs and replacement cascades appear on resources nobody touched. depends_on placed on a module is the widest version, imposing the dependency on every resource inside. When a review meets a depends_on, the first question is “could a reference express this?” — and if yes, converting it is the cleanup direction.
known after apply: counts must be fixed at plan time #
(known after apply) in plan output is not an error; it is the normal notation for values settled only after apply, like the ID of a resource being created. It becomes a problem when such a value reaches a count or for_each key. Terraform must fix how many resources exist at plan time, and if the deciding value is uncomputed, it stops with “Invalid count argument” or “Invalid for_each argument.”
The typical case is iterating for_each over attributes of resources being created in the same run. The fix is changing the key from a computed value to a static one: key on inputs already written in the code — AZ names, labels — rather than on the IDs of subnets about to be created, and the count is fixed at plan while computed values are used only inside each entry’s attributes. This is the same principle as Basics #6’s rule of keying for_each on stable strings.
Where the structure cannot change, the workaround is a two-phase apply: apply the prerequisite resources first to settle their values, then the rest. Like -target, this manufactures partial-apply states and is a stopgap, not a design. If two phases become routine, read that point as a stack boundary — the split signal, not a workflow.
Summary #
- The three problems share one root: Terraform builds the graph from references, the graph must not loop, and counts must be fixed at plan time.
- The classic Cycle is inline SG rules referencing each other. Separate rule resources make the graph one-directional; module cycles break by hoisting shared values upward.
- depends_on is for hidden dependencies only. Overuse serializes execution and spreads diffs and replacement cascades to untouched resources.
- A known-after-apply value in a count or for_each key is an error. Rekey on static inputs; two-phase apply is a stopgap.
- The diagnostic tools are the cycle list in the error and terraform graph. A workaround needed routinely marks a stack-split boundary.