TerraformI · Infrastructure as Code FoundationsFoundations
Declarative versus Imperative Provisioning
What you'll learn
- Define declarative and imperative approaches to provisioning
- Explain why Terraforms core model is declarative
- Identify the imperative escapes Terraform exposes (provisioner, lifecycle hooks, for_each dependencies)
- Recognise the order-of-execution surprise that arises from imperative thinking in a declarative tool
- Choose the right tool when the right answer is genuinely imperative
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
A junior engineer writes a Terraform configuration for a new service. The configuration declares a security group first, then an EC2 instance, then a security group rule. The apply fails. The error says the rule cannot find the security group. The engineer is convinced Terraform is broken: the file is in the right order.
The file is in the right order for a human reader. Terraform does not read in order. Terraform builds a graph. The graph is correct; the mental model is wrong. The resulting blame is the most common production-day incident caused by imperative thinking in a declarative tool.
This lesson is the disambiguation.
Definitions, in one paragraph
A declarative system is one where you describe the desired end state and a tool computes the steps. An imperative system is one where you write the steps and a tool executes them. The trade-off is explicit control (imperative) versus automatic ordering, idempotency, and plan-ability (declarative).
Imperative scripts are full of control flow:
if ! aws ec2 describe-security-groups \
--filters Name=group-name,Values=web-sg \
--query 'SecurityGroups[0].GroupId' --output text \
| grep -q '^sg-'; then
aws ec2 create-security-group --group-name web-sg \
--description "Web security group"
fi
The declarative equivalent declares the end state. The tool handles the “if it does not exist” branch automatically.
resource "aws_security_group" "web" {
name = "web-sg"
description = "Web security group"
}
Same outcome. The declarative version is reviewable, idempotent, and plan-able. The imperative version is none of those things.
Why Terraform’s core model is declarative
Terraform exists to answer a single question well: what would change if I ran this now? That question is hard to ask against an imperative script. The script does not know what it did last run. It does not know what exists. It does what it is told.
A declarative tool can answer the question because it describes the state, not the steps. Concretely, Terraforms declarative nature is visible in three properties.
Plan before apply. terraform plan produces a precise diff.
A script does not have an equivalent — it has a run, and a run
either happened or it did not.
Idempotency by construction. Re-applying the same configuration is safe. The tool compares to state and real world, and emits a no-op if nothing has changed.
Graph-ordered execution. Terraform builds a directed
acyclic graph from the configuration, walks it in dependency
order, and applies changes in parallel where independent. The
order in the .tf file is irrelevant. The graph is the truth.
The third property is the one that surprises imperative thinkers. A shell script runs top to bottom. A Terraform file is not a script. It is a set of declarations that Terraform parses into a graph.
The resource graph, in code
A tiny example. Two resources, one explicit dependency. The order in the file is wrong from a human reader’s perspective.
# File order is web rule first, then web SG.
# Terraform ignores file order.
resource "aws_security_group_rule" "web_ingress" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.web.id
}
resource "aws_security_group" "web" {
name = "web-sg"
description = "Web security group"
}
Terraform reads both blocks. It sees that web_ingress refers
to aws_security_group.web.id. It builds the graph:
aws_security_group.web
│
▼
aws_security_group_rule.web_ingress
The graph is correct. The SG is created first, then the rule. The file order does not matter. This is the declarative model working as designed.
$ terraform plan
# aws_security_group.web will be created
# aws_security_group_rule.web_ingress will be created
The imperative escapes
Terraform is declarative in its core model, but the tool exposes two well-bounded imperative escapes. They exist because some problems are genuinely imperative. Both should be used sparingly, and both should be reviewed harder than the declarative core.
provisioner blocks. Run a shell command, locally or over
SSH, after a resource is created or destroyed.
resource "aws_instance" "bootstrap" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nginx",
]
}
}
The provisioner runs after the EC2 instance is created. The commands are imperative. The wrapping resource is declarative. Mixed in the same block. This is the part that surprises people most.
lifecycle meta-arguments. Declare behaviour that the
default declarative model would not produce: create_before_destroy,
prevent_destroy, ignore_changes.
resource "aws_instance" "web" {
ami = "ami-0e1bed4f"
instance_type = "t3.medium"
lifecycle {
create_before_destroy = true
}
}
The lifecycle block is declarative about imperative-shaped
behaviour. It is the right place to encode ordering
guarantees (create the new thing before destroying the old)
without leaving the declarative model.
The cost of imperative thinking inside a declarative tool
Five patterns appear repeatedly when teams treat Terraform as a scripting language.
- Order-dependent file authoring. “I put A before B in the file.” File order is cosmetic in Terraform. The graph is what runs. Teams that author top-to-bottom write files that survive until the first refactor, then break silently.
- Hidden control flow in dependencies. A
local-execprovisioner that queries AWS and writes a value to a file that another resource reads. The plan cannot see this. The failure mode is silence. - Manual sequencing. Engineers organise PRs to “apply in a specific order.” The CLI does not honour this. The plan proposes what the graph proposes; the apply does the same.
- Idempotency assumed. A
user_datascript that is not idempotent creates duplicate resources on re-apply. The next engineer diagnoses the result as a Terraform bug. - State modifications outside the tool. Console changes made because the operator did not trust the plan. The next plan now shows drift — and the operator does not know if the drift is theirs or the tool’s.
The right mental model:
Imperative script: steps → state
Declarative tool: end state, tool computes steps → state
When you write a Terraform file, write the end state. Let Terraform compute the steps.
When the imperative escape is the right choice
There are cases where the imperative escape genuinely is the right tool. They are rare but real.
- Bootstrapping a single VM that cannot be configured by a
configuration manager. The VM has no Ansible, no Puppet,
and the cloud-init is not sufficient. A
remote-execis the smallest possible install path. - Calling a vendor CLI that has no Terraform provider. The
vendor sells a CLI. The provider does not exist. The
local-execafter a resource creation is the bridge. - One-shot migration of legacy data into a new system. A
local-execrunsmigrate.shonce; the result is captured in state; the next apply is a no-op.
In each case, the imperative piece is narrow, documented, idempotent at the boundary, and followed by a comment that explains why the declarative model was not enough. If a comment cannot be written, the imperative escape is being used where the declarative model would have worked.
How to validate the mental model
Three checks to confirm that a configuration is being read as declarative.
# READ-ONLY: confirm the graph order matches your intent.
terraform graph | head -20
# Directed graph; arrows show dependency order, not file order.
# READ-ONLY: count provisioners in the configuration.
grep -rn 'provisioner' *.tf | wc -l
# A non-zero count is acceptable; a large count is a debt.
# READ-ONLY: the plan should not show file-order surprises.
terraform plan
If terraform graph shows an order that does not match what
you wanted, the configuration is missing an explicit
dependency — typically a forgotten security_group_id = aws_security_group.web.id reference. Add the reference, not
the reordering.
Production failure modes
remote-execagainst the wrong instance. The provisioner runs against the new instance, the engineer intended the old one. The result is configuration on the wrong host. Reachable when lifecycle changes rebuild the resource.local-execreads state from disk. Alocal-execreads a file that another resource has not yet written. The apply races; the next apply produces a different result. Reachable when the dependency is implicit rather than graph-encoded.create_before_destroyforgotten. Alifecycleupgrade that needs ordered replacement omitscreate_before_destroyand a 30-minute outage ensues because the old resource is destroyed before the new one is ready.- Provisioner scripts left after a server rebuild. The
user_datais the right place for first-boot config; theremote-execis the wrong place for ongoing config. The failure appears as drift between servers. - State drift from imperative workarounds. The engineer used the console because the provisioner was too slow. The next plan shows drift that cannot be reconciled without losing the operator-introduced change.
Security implications
The imperative escapes have a different security profile from the declarative core.
local-execruns on the workstation with the engineer’s shell environment and AWS credentials. The output is shell-visible. Alocal-execthat prints secrets to stdout exposes them in CI logs.remote-execruns on the target host over SSH. The connection inherits whatever SSH key the resource block uses. A shared SSH key across production hosts is a compromise amplifier.provisionersecrets are not redacted by Terraform. Inline values such aspassword = var.db_admin_passwordappear in the plan output if they are not markedsensitive.
The default is: minimise provisioner use, and when the use is
genuine, the secret values are sensitive = true in the source
attribute and the runner has least-privilege credentials.
Performance implications
The declarative core plans efficiently because the graph is cacheable. The imperative escapes do not.
- A
local-execon every resource creation serialises the apply. Parallelism is lost. - A
remote-execover SSH has a per-invocation setup cost. Hundreds of these in a single apply turns a 30-second apply into a 30-minute apply. - A
lifecycle.create_before_destroydoubles peak resource count for the duration of the apply. In capacity-constrained environments, the apply can fail with capacity errors.
If the apply is slow, look for provisioners. The declarative core has parallel-by-default behaviour. The imperative escapes do not.
What comes next
The next lesson is the Terraform ecosystem — where Terraform sits among OpenTofu, Pulumi, Crossplane, and the vendor-managed services, and how to choose.
Verification
Five checks at PR review time confirm the configuration is being read declaratively.
# READ-ONLY: the graph should match intent, not file order.
terraform graph | head -20
# READ-ONLY: provisioners should be rare.
grep -rn 'provisioner' *.tf | wc -l
# READ-ONLY: are the explicit dependency references in place?
# A security group rule should reference its security group
# by attribute, not by name search.
grep -E 'security_group_id' *.tf
# READ-ONLY: lifecycle blocks should declare intent, not
# hide behaviour.
grep -rn 'lifecycle' *.tf
# READ-ONLY: the plan should be runnable in CI on every PR.
gh api repos/{owner}/{repo}/contents/.github/workflows \
--jq '.[].name' | grep -i plan
If the plan produced by CI matches the plan produced by the apply-time runner (modulo expected drift), the configuration is declarative. If they diverge, the configuration has imperative escapes that the graph does not capture.
Knowledge check · 7 questions
Q1. Terraforms core model is:
Q2. Which block is an imperative escape that Terraform exposes?
Q3. Order in the .tf file determines order of execution.
Q4. Which of the following are property of the imperative escape hatches in Terraform? (Select all that apply.)
Q5. Two Terraform resources have no explicit dependency on each other. How does Terraform decide the apply order?
Q6. An idempotent imperative escape matters because:
Q7. A configuration declares a security_group_rule that references a security_group, but the plan errors with 'security group not found'. What is the most likely cause?
Passing score: 75%. Answers are checked in this browser.