How Terraform manages infrastructure
How Terraform parses HCL, builds a dependency graph, calculates diffs against state, and executes changes through provider plugins via gRPC.
The Interview Question
Interviewer: "Your team uses Terraform to manage cloud infrastructure. When you run
terraform apply, what actually happens under the hood? How does Terraform know what to create, what to update, and what to destroy, without touching resources it should leave alone?"
This question tests whether you understand Terraform beyond "I write HCL and run apply." The interviewer wants to hear about the dependency graph, the state file, the diff algorithm, and how provider plugins translate your declarations into real API calls.
What to Clarify Before Answering
You: "Before I walk through the internals, let me scope this..."
- "Are we talking about the open-source Terraform CLI, or Terraform Cloud/Enterprise with remote execution?"
- "Should I focus on the plan/apply lifecycle, or also cover state management and locking?"
- "Are you interested in how provider plugins work at the protocol level, or just the high-level flow?"
- "Should I touch on modules and workspaces, or keep it to a single-root configuration?"
Why this matters: Terraform's internals span parsing, graph construction, state management, provider communication, and concurrency. Scoping the answer prevents you from spending 10 minutes on HCL syntax when the interviewer wants to hear about the dependency graph.
The 30-Second Answer
Terraform works in three phases: parse, plan, apply. First, it reads your .tf files and parses the HCL into an in-memory configuration. Then it builds a directed acyclic graph (DAG) of all resources and their dependencies. It loads the state file (a JSON snapshot of what it previously created) and diffs the desired configuration against the current state to produce a plan, listing every create, update, or destroy operation. Finally, during apply, it walks the DAG in dependency order, calling provider plugins (separate binaries communicating over gRPC) to execute each change. The state file is updated after each successful operation, and state locking (via DynamoDB, Consul, or similar) prevents concurrent runs from corrupting state.
The Architecture Overview
The diagram shows the full lifecycle. When you run terraform plan, the CLI reads your .tf files, parses them into a configuration tree, builds a dependency graph, and compares the desired state against the stored state. The diff engine produces a plan.
When you run terraform apply, the graph walker executes operations in dependency order, calling provider plugins over gRPC. Each successful operation updates the state file immediately, so a partial failure leaves state consistent with what was actually created.
The state backend and locking layer sit underneath everything, ensuring that two engineers running terraform apply simultaneously do not corrupt the state file. I will walk through each of these layers in detail.
HCL Parsing and Configuration Evaluation
Terraform's configuration language, HCL (HashiCorp Configuration Language), is not just a data format. It is an expression language with variables, functions, conditionals, and iteration. Understanding how Terraform evaluates it explains many of the behaviors that surprise people.
The Parsing Pipeline
When Terraform reads your .tf files, it goes through three stages:
- Lexical analysis: The HCL parser tokenizes the file into blocks, attributes, and expressions
- Structural parsing: Tokens are organized into a block tree (resource blocks, variable blocks, data blocks, etc.)
- Expression evaluation: References like
var.name,aws_vpc.main.id, and function calls likecidrsubnet()are resolved
One detail that surprises people: Terraform merges all .tf files in a directory into a single configuration. There is no import order or file precedence. Every .tf file in the working directory is part of the same module. This is why you can define a variable in variables.tf and reference it in main.tf without any import statement.
Why this matters in production
Because all files merge into one namespace, naming collisions across files cause hard-to-debug errors. I always use a consistent file naming convention: main.tf for resources, variables.tf for inputs, outputs.tf for outputs, and providers.tf for provider configuration. This is not a Terraform requirement, just a convention that prevents confusion.
Expression Evaluation Order
Terraform evaluates expressions lazily during the plan phase, not during parsing. This means:
var.xis resolved when the resource that uses it is being plannedaws_vpc.main.idis resolved using the state file (for existing resources) or marked as "known after apply" (for new resources)countandfor_eachare evaluated early because they determine how many resource instances exist
// Simplified expression evaluation
func evaluateExpression(expr Expression, scope EvalScope) (Value, Diagnostics) {
switch e := expr.(type) {
case *LiteralExpr:
return e.Value, nil // "hello" -> "hello"
case *ReferenceExpr:
return scope.Lookup(e.Subject) // var.name -> "production"
case *FunctionCallExpr:
args := evaluateAll(e.Args, scope) // cidrsubnet("10.0.0.0/16", 8, 1)
return callFunction(e.Name, args) // -> "10.0.1.0/24"
case *ConditionalExpr:
cond := evaluate(e.Condition, scope) // var.env == "prod" ? "m5.xlarge" : "t3.micro"
if cond.True() { return evaluate(e.TrueResult, scope) }
return evaluate(e.FalseResult, scope)
}
}
The "known after apply" concept is important. When Terraform plans a new resource, attributes like id or arn do not exist yet. Terraform marks them as unknown and propagates that unknown-ness through any expression that depends on them. This is why you see (known after apply) in plan output.
The Dependency Graph (DAG)
The dependency graph is the heart of Terraform. It determines the order of operations and enables parallel execution. I find this the most elegant part of Terraform's design.
How Terraform Builds the Graph
Terraform constructs a directed acyclic graph where:
- Nodes are resources, data sources, variables, outputs, and providers
- Edges represent dependencies (this resource must be created before that one)
Dependencies come from two sources:
- Implicit dependencies: Terraform analyzes expressions. If
aws_instance.webreferencesaws_security_group.web.id, Terraform adds an edge from the security group to the instance. - Explicit dependencies: The
depends_onmeta-argument forces an edge even when there is no expression reference.
Looking at this graph, Terraform knows it must create the VPC first. Then it can create subnets, security groups, and the internet gateway in parallel because they have no dependencies on each other. Only after the subnet and security group exist can it create the EC2 instance and RDS database.
Parallel Execution with the Graph Walker
Terraform's graph walker uses a semaphore-based approach to execute independent nodes in parallel. By default, it runs up to 10 operations simultaneously (configurable with -parallelism).
// Simplified graph walker
func walkGraph(graph *DAG, parallelism int) error {
sem := make(semaphore, parallelism) // Default: 10
for {
ready := graph.NodesWithNoDependencies() // Find nodes with all deps satisfied
if len(ready) == 0 { break }
for _, node := range ready {
sem.Acquire() // Block if 10 already running
go func(n Node) {
defer sem.Release()
err := n.Execute() // Call provider plugin
if err != nil {
graph.MarkFailed(n) // Dependent nodes will be skipped
} else {
graph.MarkComplete(n) // Remove from pending
}
}(node)
}
}
}
Common mistake: over-using depends_on
I see teams add depends_on everywhere "just to be safe." This is counterproductive. Every explicit dependency reduces parallelism. If resource A depends on resource B only through depends_on (not through an actual attribute reference), Terraform must serialize them even though they could run in parallel. Only use depends_on when Terraform cannot infer the dependency from expressions, such as IAM policies that affect permissions but are not referenced by ARN.
Destroy Order
When destroying resources, Terraform reverses the graph. Resources that depend on others are destroyed first. The EC2 instance is terminated before the subnet is deleted, the subnet before the VPC. This prevents "resource in use" errors from cloud APIs.
Graph Validation and Cycle Detection
Before execution, Terraform validates that the graph is acyclic. If it detects a cycle (A depends on B, B depends on C, C depends on A), it aborts with an error. Cycles usually come from circular depends_on references or module outputs that reference each other.
Terraform uses a depth-first search (DFS) based topological sort to detect cycles. The algorithm marks nodes as "visiting" during traversal and "visited" when complete. If it encounters a node that is already "visiting," it has found a back edge (a cycle).
// Simplified cycle detection
func detectCycles(graph *DAG) error {
visited := map[Node]string{} // "visiting" or "visited"
for _, node := range graph.Nodes() {
if visited[node] == "" {
if err := dfs(node, visited, graph); err != nil {
return err // Cycle found
}
}
}
return nil
}
func dfs(node Node, visited map[Node]string, graph *DAG) error {
visited[node] = "visiting"
for _, dep := range graph.Dependencies(node) {
if visited[dep] == "visiting" {
return fmt.Errorf("cycle: %s -> %s", node, dep)
}
if visited[dep] == "" {
if err := dfs(dep, visited, graph); err != nil {
return err
}
}
}
visited[node] = "visited"
return nil
}
Debugging graph issues
When you get a cycle error, run terraform graph | dot -Tsvg > graph.svg to visualize the dependency graph. This outputs DOT format that Graphviz can render. I have found this invaluable for untangling complex module dependencies in large codebases.
State File Internals
The state file is Terraform's memory. Without it, Terraform has no idea what it has previously created. Understanding its structure explains many of the "why did Terraform want to destroy and recreate this?" mysteries.
JSON Structure
The state file (terraform.tfstate) is a JSON document with this structure:
{
"version": 4,
"terraform_version": "1.7.0",
"serial": 42,
"lineage": "a1b2c3d4-e5f6-...",
"outputs": { },
"resources": [
{
"module": "module.vpc",
"mode": "managed",
"type": "aws_vpc",
"name": "main",
"provider": "provider[\"registry.terraform.io/hashicorp/aws\"]",
"instances": [
{
"schema_version": 1,
"attributes": {
"id": "vpc-0abc123",
"cidr_block": "10.0.0.0/16",
"tags": { "Name": "production" }
}
}
]
}
]
}
Key fields:
- serial: Incremented on every write. Used for optimistic locking.
- lineage: A UUID generated on
terraform init. Prevents accidentally applying state from a different environment. - Resource addresses: Uniquely identify resources using the format
module.name.type.name[index], likemodule.vpc.aws_subnet.public[0]. - Attribute snapshots: Every attribute of every resource is stored, not just the ones you defined. This includes computed attributes like
id,arn, andcreated_at.
Secrets in state
The state file contains every attribute value in plain text, including passwords, API keys, and database connection strings. If your RDS resource has password = var.db_password, that password is stored unencrypted in the state file. This is why remote state with encryption (S3 + SSE) and access controls is not optional for production use.
Remote State and Locking
For teams, local state files are a disaster. Two engineers running terraform apply simultaneously will overwrite each other's state. Terraform solves this with remote backends and state locking.
The most common production setup is S3 + DynamoDB:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
The locking flow:
- Terraform writes a lock record to DynamoDB with a unique lock ID, the user's identity, and a timestamp
- If a lock already exists, Terraform fails with "Error acquiring the state lock" and shows who holds it
- After apply completes, Terraform deletes the lock record
- If Terraform crashes mid-apply, the lock remains (requires manual
terraform force-unlock)
Provider Plugins and the gRPC Protocol
Providers are where Terraform meets the real world. Each provider (AWS, GCP, Azure, Kubernetes, etc.) is a separate binary that Terraform communicates with over gRPC. This plugin architecture is what makes Terraform extensible without modifying the core.
Plugin Lifecycle
When you run terraform init, Terraform:
- Reads provider requirements from your configuration
- Downloads the provider binary from the Terraform Registry (or a mirror)
- Verifies the binary's SHA256 checksum against the signed hash
- Stores it in
.terraform/providers/
When Terraform needs to execute an operation, it:
- Launches the provider binary as a child process
- Establishes a gRPC connection over a local Unix socket (or named pipe on Windows)
- Sends RPC calls for schema discovery, planning, and applying
- Kills the provider process when done
The CRUD Lifecycle
Every provider resource implements six RPC methods:
The critical distinction: PlanResourceChange does not call the cloud API. It only computes what would change based on the schema and current state. ApplyResourceChange makes the actual API call. This is why terraform plan is safe to run at any time.
// Simplified provider Apply flow
func (p *AWSProvider) ApplyResourceChange(req ApplyRequest) ApplyResponse {
switch {
case req.PriorState == nil:
// CREATE: No prior state means new resource
result, err := p.client.CreateVPC(req.PlannedState)
return ApplyResponse{NewState: result, Error: err}
case req.PlannedState == nil:
// DESTROY: No planned state means delete
err := p.client.DeleteVPC(req.PriorState.ID)
return ApplyResponse{NewState: nil, Error: err}
default:
// UPDATE: Both states exist, apply changes
result, err := p.client.ModifyVPC(req.PriorState.ID, req.PlannedState)
return ApplyResponse{NewState: result, Error: err}
}
}
Why this matters for debugging
When Terraform shows "forces replacement" in the plan, it means the provider's schema marks that attribute as ForceNew. The provider knows that the cloud API does not support in-place updates for that attribute (like changing an EC2 instance's AMI), so it must destroy and recreate. Understanding this helps you predict which changes are safe and which cause downtime.
Modules and Workspace Isolation
Modules are Terraform's unit of reuse, and workspaces provide environment isolation. Together they allow teams to manage multiple environments from a single codebase.
Module Internals
A module is just a directory of .tf files with input variables and output values. When you call a module:
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
env = "production"
}
Terraform treats it as a nested scope:
- The module's variables become its input interface
- The module's outputs become its output interface
- Resources inside the module get prefixed addresses:
module.vpc.aws_vpc.main - The module is a node in the dependency graph, with edges based on which outputs are consumed
Modules can nest (module A calls module B calls module C), creating a tree. Terraform flattens this tree into a single dependency graph before execution.
Workspaces
Workspaces allow multiple state files for the same configuration. Each workspace has its own state, so terraform workspace select staging switches to the staging state without changing any code.
Internally, workspaces are just subdirectories in the backend. For an S3 backend, workspace "staging" stores state at env:/staging/terraform.tfstate.
I recommend workspaces only for simple environment differences (dev/staging/prod with the same topology). For environments with different resources or significantly different configurations, use separate root modules instead.
The Plan/Apply Lifecycle (End to End)
Let me walk through the complete flow of what happens when you run terraform plan followed by terraform apply.
Notice that Terraform writes state after each resource operation, not at the end. This is a deliberate design choice. If Terraform crashes after creating 5 of 10 resources, the state file accurately reflects those 5 resources. The next terraform apply picks up where it left off.
Terraform Import and State Manipulation
Sometimes you need to bring existing resources under Terraform management, or fix state that has drifted from reality.
terraform import adopts an existing resource by ID:
terraform import aws_vpc.main vpc-0abc123
This calls the provider's ImportResourceState RPC, which fetches all attributes from the cloud API and writes them into state. You still need to write the corresponding HCL configuration manually (Terraform 1.5+ has import blocks that can generate config).
terraform state commands manipulate state directly:
terraform state mvrenames a resource address (e.g., after refactoring modules)terraform state rmremoves a resource from state without destroying itterraform state pull/pushdownloads or uploads the raw state file
State manipulation is dangerous
Running terraform state rm on a resource means Terraform forgets it exists. The next apply will try to create a duplicate. Running terraform state mv with the wrong address can orphan resources. Always run terraform state pull and back up state before any manual manipulation.
What Happens When Things Break
| Failure | What Happens | How to Detect | How to Fix |
|---|---|---|---|
| Provider API error mid-apply | Terraform marks the resource as "tainted" and stops dependent resources. State is consistent up to the failure point. | Error message in CLI output. terraform show shows tainted resources. | Fix the underlying issue (quota, permissions, naming conflict) and re-run terraform apply. |
| State lock stuck (crash during apply) | No one can run plan or apply. Lock remains in DynamoDB. | "Error acquiring the state lock" with the lock ID and holder info. | terraform force-unlock LOCK_ID after confirming no other apply is running. |
| State drift (manual console change) | Plan shows unexpected changes because the state does not match reality. | terraform plan shows updates or replacements you did not expect. | Run terraform refresh (or terraform apply -refresh-only) to sync state with reality, then decide whether to keep or revert the manual change. |
| Corrupted state file | Terraform fails to parse state. All operations fail. | JSON parse errors on any Terraform command. | Restore from the S3 versioned backup. If no backup exists, reconstruct state using terraform import for each resource. |
| Provider version mismatch | Resources fail to plan because the schema changed between versions. | Schema errors or unexpected attribute changes in plan output. | Pin provider versions in required_providers and use a lock file (.terraform.lock.hcl). |
Drift Detection and Recovery Flow
When state does not match reality, Terraform's behavior depends on the direction of drift:
If someone changes a security group rule in the console and the rule is defined in your HCL, Terraform will plan to revert it. If they change a tag that is not in your HCL, Terraform will silently adopt the change into state. Understanding this distinction prevents confusion when drift is detected.
Performance Characteristics
| Operation | Typical Duration | What Affects It |
|---|---|---|
terraform init | 5-30 seconds | Number of providers, network speed to registry |
terraform plan (small, <50 resources) | 10-30 seconds | Number of resources, provider API latency for refresh |
terraform plan (large, 500+ resources) | 2-10 minutes | State file size, number of API calls for refresh |
terraform apply (10 resources) | 1-5 minutes | Cloud API latency, resource creation time |
terraform apply (100+ resources) | 5-30 minutes | Parallelism setting, dependency chain depth |
| State lock acquire | < 1 second | DynamoDB latency (single-digit ms) |
| State file read/write | 1-5 seconds | State file size (can exceed 50MB for large infra) |
The biggest performance bottleneck in large deployments is the refresh phase. Terraform calls ReadResource for every resource in state to detect drift. For 500 resources, that is 500 API calls. You can skip this with -refresh=false, but then you miss drift detection.
Key insight
If your terraform plan is slow, the problem is almost always the refresh phase. Split large state files into smaller roots (network, compute, database) so each plan only refreshes relevant resources. I have seen teams reduce plan time from 8 minutes to 30 seconds by splitting a monolithic state.
How This Compares to Alternatives
| Feature | Terraform | Pulumi | CloudFormation | Ansible |
|---|---|---|---|---|
| Language | HCL (declarative) | Python/TypeScript/Go (imperative) | JSON/YAML (declarative) | YAML (procedural) |
| State management | Explicit state file | Explicit state (Pulumi Cloud or self-managed) | AWS-managed (no file to manage) | Stateless (idempotent modules) |
| Multi-cloud | Yes (any provider) | Yes (any provider) | AWS only | Yes (via modules) |
| Plan/preview | terraform plan | pulumi preview | Change sets | --check mode (limited) |
| Dependency graph | Automatic from expressions | Automatic from code references | Automatic from Ref/DependsOn | Explicit task ordering |
| Rollback | No native rollback (apply forward) | No native rollback | Automatic rollback on failure | Re-run previous playbook |
| Ecosystem | Largest provider registry (3,000+ providers) | Growing, wraps Terraform providers | AWS-native only | Large module galaxy |
| Secret handling | No built-in encryption (state has secrets in plain text) | Built-in secret encryption | Parameter Store / Secrets Manager integration | Ansible Vault |
I reach for Terraform when managing multi-cloud or hybrid infrastructure because the provider ecosystem is unmatched. I use CloudFormation only for AWS-only shops that want managed state. I recommend Pulumi when the team has strong programming language skills and wants to use real control flow (loops, conditionals, type checking) instead of HCL's limited expressions.
Ansible is not a substitute for Terraform. Ansible is for configuration management (installing packages, configuring services on existing servers). Terraform is for infrastructure provisioning (creating servers, networks, databases). Use both together: Terraform creates the EC2 instance, Ansible configures it.
When to Choose What
Here is my decision framework based on real project experience:
- Greenfield multi-cloud project: Terraform. The provider ecosystem covers AWS, GCP, Azure, Kubernetes, Datadog, PagerDuty, and hundreds more. No other tool comes close for multi-cloud.
- AWS-only shop with small team: CloudFormation is viable. Managed state means less operational overhead. The trade-off is inferior developer experience and slower iteration (CloudFormation updates can take 10-15 minutes to validate).
- Team of strong TypeScript/Python developers: Consider Pulumi. Real programming language means real IDEs, real debugging, real testing frameworks. No learning HCL. But the ecosystem is smaller and the community is a fraction of Terraform's.
- Configuration on existing servers: Ansible. Terraform creates the box, Ansible configures it. They complement each other.
- Kubernetes-only infrastructure: Helm + Kustomize may suffice. You do not need Terraform for resources that live entirely inside Kubernetes, unless you also manage the cluster itself.
Key production insight
I have seen the most successful infrastructure teams use Terraform for cloud resources (VPCs, databases, IAM, DNS) and a separate tool for application-level configuration (Helm for Kubernetes, Ansible for VMs). Trying to manage everything in Terraform, including application config, helm releases, and kubectl manifests, creates a state file that is too large and an apply that is too slow. Separate concerns, separate state.
Interview Cheat Sheet
- When asked what Terraform does: "Terraform is a declarative infrastructure-as-code tool. You describe the desired state in HCL, and Terraform figures out the sequence of API calls to reach that state."
- When asked about the plan phase: "Plan builds a dependency graph from HCL expressions, diffs desired state against the state file, and produces a list of create/update/destroy operations without making any API calls."
- When asked about the state file: "State is a JSON file that maps resource addresses to real infrastructure IDs and all their attributes. It is the source of truth for what Terraform has created."
- When asked about state locking: "Remote backends like S3+DynamoDB use a lock table to prevent concurrent applies. The lock includes a unique ID, the user's identity, and a timestamp."
- When asked about providers: "Providers are separate binaries that communicate with Terraform core over gRPC. Each resource type maps to CRUD operations that the provider translates into cloud API calls."
- When asked about dependencies: "Terraform infers dependencies from expression references. If resource A references resource B's ID, Terraform creates B first. The graph enables parallel execution of independent resources."
- When asked about import: "Terraform import reads an existing resource's attributes from the cloud API and writes them into state. You still need to write the HCL configuration to match."
- When asked about modules: "Modules are directories of .tf files with input variables and outputs. They create a nested scope in the dependency graph and enable code reuse across environments."
- When asked about drift detection: "Terraform detects drift during the refresh phase of plan, when it calls the provider's ReadResource for every resource and compares the result to stored state."
- When asked about rollback: "Terraform has no native rollback. If an apply fails partway, state reflects what was actually created. You fix the issue and apply again, or use
terraform state rmto remove the partially created resource."
Test Your Understanding
Quick Recap
- Terraform parses all
.tffiles in a directory into a single configuration, evaluating HCL expressions lazily during the plan phase. - The dependency graph (DAG) is built from expression references and explicit
depends_on, determining execution order and enabling parallel operations. - The plan phase diffs desired configuration against the state file without making any cloud API calls (except during the refresh step to detect drift).
- Provider plugins are separate binaries communicating over gRPC, implementing CRUD operations that translate HCL declarations into real API calls.
- The state file is a JSON document storing every resource's address, provider, and complete attribute snapshot, including sensitive values in plain text.
- Remote backends (S3, GCS, Terraform Cloud) centralize state, and lock tables (DynamoDB, Consul) prevent concurrent applies from corrupting it.
- Terraform writes state after each successful resource operation, so partial failures leave a consistent (if incomplete) state.
- Modules create nested scopes in the dependency graph, and splitting state into independent roots is the most effective way to reduce blast radius and improve plan performance.
Related Concepts
- Infrastructure as Code patterns: Terraform is one approach to IaC. Understanding the broader category helps you choose between Terraform, Pulumi, and CloudFormation.
- Directed Acyclic Graphs (DAGs): The same graph algorithms Terraform uses for dependency ordering appear in build systems (Make, Bazel), task schedulers (Airflow), and package managers.
- gRPC and Protocol Buffers: Terraform's provider protocol uses the same gRPC framework used in microservice communication. Understanding gRPC helps debug provider issues.
- Optimistic concurrency control: Terraform's state serial number is a form of optimistic locking, the same pattern used in database systems and distributed consensus.
- Configuration management vs provisioning: Understanding the boundary between Terraform (infrastructure) and Ansible/Chef/Puppet (configuration) is a common interview topic.
title: "How Terraform plans and applies infrastructure" description: "How Terraform builds a dependency graph, calculates diffs against state, parallelizes resource creation, and handles state locking." tags:
- "terraform"
- "infrastructure"
- "devops"
- "how-things-work" difficulty: "medium" category: "situational/how-things-work" order: 11 publishedAt: "2026-04-12" relatedArticles: []
Stub
This article is planned but not yet written. See the instruction files for writing guidelines.