Terraform showed up in 18.7 percent of professional developers' toolchains in the 2025 Stack Overflow Developer Survey, just ahead of Ansible's 11.2 percent and well behind Docker's 73.8 percent (Stack Overflow, 2025). That adoption shows up in hiring: Terraform interview questions have gotten sharper about state and drift, and noticeably less about basic HCL syntax.
Here's an opinion that might be wrong: most Terraform interview prep spends too long on resource blocks and variable types, and not enough on state. Syntax is something you look up mid-task. State is what actually breaks in production, two people applying at once, a config that drifted weeks ago, an import that only half-worked. Those questions separate someone who's read the docs from someone who's operated Terraform for a team that depends on it.
This page covers the Terraform interview questions that come up across DevOps, SRE, and platform engineering loops in 2026: IaC fundamentals, providers and resources, state (study this hardest), modules, variables and locals, workspaces, provisioners, the plan-apply-destroy lifecycle, drift, import, and a handful of functions questions. Most loops don't ask about Terraform on its own, it's usually one twenty-minute segment inside a broader cloud interview.
Infrastructure as code basics: Terraform vs CloudFormation, Pulumi, and Ansible
Every loop opens here, even for senior candidates, and it moves fast. The thing interviewers actually listen for is whether you can name a real tradeoff instead of reciting marketing copy.
Easy questions
15Infrastructure as Code means describing servers, networks, and other infra as text files instead of clicking through a cloud console, then applying that text to create or change the real thing. Terraform reads the files, compares them against what it last recorded, and changes only what's different.
The problem it solves isn't just "typing is faster than clicking." Console changes are undocumented and unrepeatable, nobody can tell you exactly what someone clicked six months ago. A.tf file sitting in git is the opposite: reviewable, diffable, reproducible.
A provider is a plugin translating your HCL resource blocks into real API calls against a specific platform, AWS, Azure, Kubernetes, whatever you declared in required_providers. terraform init reads that block, resolves the version constraint, and downloads the matching plugin from the Terraform Registry, or a private mirror, into a local.terraform directory.
It also initializes the backend and pulls in any modules referenced by source. Skipping init isn't optional, plan and apply error out immediately without the plugin present.
A resource block is something Terraform creates, owns, and can destroy. It shows up in state with a real-world ID Terraform is now responsible for. A data source is read-only: it looks up something that already exists, an AMI, an existing VPC, a DNS zone someone else manages, and pulls its value into your config without touching its lifecycle.
The tell in an interview: if changing your config could delete the thing, it's a resource. If it can only ever read a value and never modify or remove what it's reading, it's a data source.
State is a JSON file, usually terraform.tfstate, mapping every resource address in your config to the real-world object it created, plus a cache of that object's attributes. It's how Terraform knows aws_instance.app in your config is actually i-0e9f8a2b1c3d4e5f6 in AWS.
Checking real infrastructure fresh every time would be slow, rate-limited API calls across thousands of resources add up, and worse, incomplete. A randomly generated database password, say, exists only at creation time and isn't retrievable from the provider's API afterward. State is the only place Terraform ever recorded it.
A module is a reusable, parameterized group of resources you call with a module block, passing in variables and reading back outputs, the same idea as calling a function with arguments and a return value.
The honest answer on timing: not on the first copy, probably not the second either. Write the module once you're about to paste the same five-resource pattern into a third place. Extracting too early usually means guessing at the wrong parameters, since you don't yet know which parts actually vary between call sites.
A variable is an input, set from outside the module: a tfvars file, the CLI, an env var, or a caller's module block. A local is a named expression computed inside the module for reuse, it can't be set from outside at all. An output is a value the module hands back once applied, something a parent module or you at the CLI can read or pass into another resource.
Quick way to keep them straight: variables flow in, outputs flow out, locals never leave the module they're defined in.
plan computes the diff between your config and current state without touching any real infrastructure, a dry run. apply executes that diff against the real provider APIs.
Yes, apply can diverge from what plan showed. If state changed in between, someone else applied first, or drift occurred, Terraform reconciles against the newer reality at apply time. The fix is terraform plan -out=tfplan followed by terraform apply tfplan, which locks in the exact plan you reviewed instead of recomputing one fresh.
A function, lookup(), join(), cidrsubnet(), and dozens more, is a pure expression evaluated inside HCL itself at plan time. No API calls, no side effects, no entry in state. A resource block is the opposite: it hits a real provider API and creates or manages something external.
Functions can transform values you already have. They can't create infrastructure on their own, that's what resources and data sources exist for.
A backend controls three things: where state is stored, how state locking works, and how state operations get authenticated. The S3 backend for example needs a DynamoDB table (or S3 native locking in newer versions) to prevent two applies from writing state at the same time, and it needs IAM permissions separate from whatever the resources themselves need.
Terraform Cloud's backend additionally provides remote execution, so the actual plan and apply run on HashiCorp's infrastructure instead of your laptop, which changes how variables, workspaces, and even provider credentials get injected. Switching backends isn't just a config change either, you have to run terraform init -migrate-state to physically move the existing state file into the new backend, and if you skip that step Terraform will happily start a brand new empty state and try to recreate everything.
A.tf file is HCL that defines resources, variables, outputs, providers, anything that shapes what Terraform builds. A.tfvars file only assigns values to variables that were already declared in a.tf file, it can't declare new variables or resources on its own.
Terraform auto-loads any file named terraform.tfvars or matching *.auto.tfvars without you passing a flag, but anything named differently, like prod.tfvars, needs an explicit -var-file=prod.tfvars on the command line or it's silently ignored. That's a common source of "why didn't my variable take effect" bugs, someone renames a file and forgets it's no longer auto-loaded.
terraform fmt only rewrites formatting, indentation, alignment of the equals signs, quote style. It doesn't know or care whether your configuration is correct, a file full of syntax errors can still get reformatted around those errors.
terraform validate checks that the configuration is internally consistent: correct HCL syntax, required arguments present, variable types matching, references pointing to things that actually exist in the config. What validate can't do is check anything against real infrastructure or provider credentials, so a resource block that's syntactically perfect but references an AWS AMI ID that doesn't exist will pass validate and only fail later during apply.
Providers ship independently from Terraform core and each version can change resource schemas, add or remove arguments, or alter default behavior. Without a version constraint, a fresh terraform init on a new machine could pull a provider release two years newer than what the config was written against, and a schema change alone can turn a clean plan into dozens of unexpected diffs.
The required_providers block combined with the lock file is what makes a config reproducible across machines and CI runners. A loose constraint like ">= 4.0" is basically asking for a future breaking release to sneak in during a routine pipeline run.
It holds the downloaded provider plugin binaries, any modules pulled from a registry or git source, and a small file recording which backend is configured. None of it is source of truth, it's all reconstructable by running terraform init again.
It's safe to delete and regenerate any time you're debugging a weird provider issue or the plugin cache seems corrupted, and it should never be committed to version control, both because of size and because it can contain machine-specific paths. The one thing people sometimes confuse it with is the state file, which lives elsewhere (or remotely) and absolutely should not be deleted the same way.
terraform_data, added in Terraform 1.4, is the modern equivalent of the old null_resource pattern. Both exist for the same reason: sometimes you need a resource-shaped thing in your graph that doesn't correspond to any real infrastructure, just to trigger a provisioner, force a dependency, or hold a "triggers" value that causes downstream resources to recreate when something changes.
A common use is pairing it with a local-exec provisioner to run a one-off script whenever an input value changes, since Terraform has no native way to run a command on every apply. It's a workaround more than a feature, if you find yourself reaching for it often it's usually a sign the real logic belongs in a proper provider or an external system instead of a Terraform hack.
The lock file records the exact provider versions and their cryptographic hashes that were resolved the last time someone ran terraform init in that directory. Without it committed, two developers, or a developer and CI, could resolve different provider versions that both satisfy the same version constraint, and get different behavior or even different plans for identical code.
It's the same idea as a package-lock.json or Gemfile.lock, the constraint says what's allowed, the lock file says what's actually installed. If you do want to intentionally upgrade a provider, you run terraform init -upgrade, which recalculates the lock file, and that diff should get reviewed and committed like any other change.
Medium questions
25CloudFormation is AWS's own service. It only manages AWS resources, and AWS tracks state internally through stacks, so there's no separate state file to lose or corrupt. Terraform is provider-agnostic: the same HCL syntax manages AWS, Azure, GCP, Datadog, Cloudflare, and a few hundred other providers, but you own the state file yourself, on a backend you configure.
The multi-cloud pitch gets oversold, most companies I've seen run on exactly one cloud, not three. But even single-cloud AWS shops often pick Terraform anyway, since half their infra isn't AWS, it's Datadog dashboards, GitHub settings, PagerDuty schedules, things CloudFormation has no concept of. That ecosystem breadth is the real reason Terraform wins, not multi-cloud portability almost nobody uses.
Terraform describes an end state and figures out the diff itself. Say "three web servers should exist" and it works out what to create, change, or destroy to get there. Ansible runs an ordered list of tasks against a target instead: install this package, copy this file, restart that service, in the sequence you wrote them.
Terraform is built for provisioning: the VM, the network, the load balancer. It's a worse fit for ongoing config on a box that already exists, installing packages, restarting a service. That's Ansible's strength, and it's why HashiCorp tells you not to lean on Terraform's provisioners for that (more a few sections down). Plenty of teams run both, Terraform provisions, Ansible configures.
Terraform builds a dependency graph, a DAG, from every reference between resources. If a security group's ID gets passed into an EC2 instance's config, Terraform sees that reference and creates the security group first, since the instance needs a real ID to attach to.
resource "aws_security_group" "web" {
name = "web-sg"
}
resource "aws_instance" "app" {
ami = "ami-0abcd1234"
instance_type = "t3.micro"
vpc_security_group_ids = [aws_security_group.web.id]
}Resources with no reference to each other have no implied order, so Terraform applies them in parallel, which is why a plan touching 40 unrelated resources finishes faster than most people expect. When a dependency exists but isn't expressed through an attribute reference, that's what the explicit depends_on argument is for.
Local state means terraform.tfstate sits on whoever's laptop ran apply last. The obvious risk is losing the laptop. The bigger risk on a team is that nobody else has a current view of reality, two people can run Terraform against what they each think is current and step on each other, since there's no shared lock.
There's a second problem people miss: state routinely contains plaintext secrets, a generated password, an API token some provider returned on creation. A local state file committed to git, it happens more than it should, leaks those secrets into history forever. Remote backends like S3, GCS, or Terraform Cloud centralize the file for the team and, done properly, add encryption at rest.
Secrets sitting in plaintext. Mark a variable sensitive = true and Terraform hides it from plan and apply output on your terminal, but that flag does nothing to the state file itself. terraform state show on that same resource, or anyone with read access to the backend bucket, sees the raw value.
Encrypting the backend at rest and locking down IAM access isn't optional hardening, it's the only real protection state secrets get. Most teams I've seen don't treat their state bucket with anywhere near the access controls of an actual secrets manager, even though it holds the same kind of data.
A validation block inside a variable definition runs at plan time, before Terraform touches a single provider API, and rejects bad input immediately with a message you write yourself.
That timing matters. Catching a bad environment string in application code happens after infrastructure may already be half-created. Catching it in a validation block means the apply never starts. It's a small thing, and it's exactly the kind of small thing that separates a config someone else can safely run from one they're scared to touch.
It creates a new, separate state namespace within the same backend and the same.tf configuration, terraform.tfstate.d/staging sitting alongside default. Resources tracked under the staging workspace are entirely separate entries in the state, invisible to the default workspace and vice versa.
It does not duplicate your configuration files or vary any values automatically. Your.tf files stay identical across every workspace unless you write conditional logic, keyed off terraform.workspace, to make staging differ from prod.
Because everything shares one root config and typically one credential setup. A bug in your terraform.workspace conditional logic, or someone forgetting to switch workspace before applying, can point what you thought was a staging change at prod resources instead, since the guardrail is a string flag, not a separate directory or separate credentials.
My take here, and plenty of people will push back: workspaces are fine for short-lived, throwaway environments, a feature branch's test stack, where the blast radius of a mistake is small. For prod versus everything else, most teams are better off with separate root modules, or at minimum separate state files and credentials per environment, so a mistake in one can't reach the others.
A provisioner runs a script during a resource's creation or destruction: local-exec on the machine running Terraform, remote-exec over SSH or WinRM into the new resource itself. HashiCorp's documentation is blunt about it, stating you should exhaust all alternatives before using provisioners in your configurations at all (Terraform docs, provisioners).
The reason is architectural, not stylistic. Everything else in Terraform is declarative and tracked in state. A provisioner is an imperative script bolted onto that model, and Terraform has no real visibility into what it did, whether it's idempotent, or whether it half-succeeded. It reintroduces the same order-dependent scripting infrastructure as code exists to get away from.
Drift is when real infrastructure no longer matches what's recorded in state, someone edited a security group rule by hand in the console, or an out-of-band automated process changed a tag Terraform doesn't know about.
Terraform refreshes state against the real provider before computing a diff, so a plain terraform plan usually surfaces drift as an unexpected change. To check for drift specifically, without risking any other planned change slipping in alongside it, terraform plan -refresh-only isolates exactly that.
terraform destroy -target=resource.address, or the newer terraform apply -destroy -target=resource.address, scopes the destroy to just that resource and whatever depends on it.
HashiCorp is upfront that -target is for exceptional recovery situations, not routine use, because it can leave state and the dependency graph in a shape a normal full-config plan wouldn't produce. It's the right tool maybe twice a year, not a habit.
Both force Terraform to destroy and recreate a specific resource on the next apply, even though nothing in its tracked config or attributes changed. terraform taint is the older, now-deprecated command since roughly Terraform 0.15. terraform apply -replace="resource.address" does the same thing without a separate state-mutating step first.
The use case: something's wrong with the running resource in a way Terraform can't see, a corrupted disk, a cert that needs rotating. Terraform's diff engine only reacts to config or attribute changes. If the resource is broken in a way invisible to both, replace is how you tell Terraform to rebuild it anyway.
A for expression iterates a collection and produces a new list or map. Turning a list of names into a map keyed by index looks roughly like a loop over each element with its position, building key-value pairs out of them as it goes.
variable "azs" {
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
locals {
az_index_map = { for idx, az in var.azs : az => idx }
}That pattern shows up constantly feeding for_each downstream, since for_each needs a map or set, not a plain list, and a raw list of strings from a variable usually needs exactly this kind of reshaping first.
Pin provider versions with a narrow constraint, not just a floating >=. An unpinned provider silently picking up a new major version on a routine terraform init in CI has broken production applies before, a provider update changes a resource's default behavior or a required argument, and the first anyone hears about it is a failed apply against real infrastructure, not a changelog they read in advance.
Smaller but worth naming too: never hand-edit a state file in a text editor. Use terraform state mv or terraform state rm for surgical changes, both of which understand the file's structure and update dependent references correctly. A manual edit that looks harmless can leave state internally inconsistent in ways that only surface three commands later.
A module's outputs are only visible to whatever calls it, they don't automatically propagate anywhere. In the root module, or a parent module, you reference the first module's output as module.network.vpc_id and pass it in as an input variable to the second module's block.
That reference is also what creates the dependency edge in Terraform's graph, so the database module's resources won't even be planned for creation until the network module's relevant resources exist. The mistake people make is trying to reference module outputs across sibling modules directly, that doesn't work, all the wiring has to flow through the module block arguments in the calling module.
You need a dynamic block when the number of nested blocks isn't known until you have actual data, like security group rules coming from a variable list where sometimes there are two rules and sometimes there are five. Static nested blocks in Terraform can't be generated with count or for_each the way top-level resources can, so without dynamic blocks you'd have to hardcode a fixed number of optional blocks and leave most of them empty.
A dynamic ingress block over a list of rule objects turns that into one construct that expands to however many entries exist. The tradeoff is readability, dynamic blocks make the config harder to scan visually, so for a small fixed set of two or three known rules it's often clearer to just write them out normally.
The terraform_remote_state data source points at another config's backend, an S3 bucket and key, a Terraform Cloud workspace, whatever it uses, and exposes that state's outputs as attributes, so data.terraform_remote_state.network.outputs.vpc_id becomes available. It's commonly used to split a large infrastructure into separately applied layers, a networking config, a shared services config, and per-app configs, without collapsing everything into one giant state.
The catch is that it reads the raw state file, which means every value in that state's outputs block is exposed, including anything accidentally marked non-sensitive that shouldn't be. Because of that a lot of teams now prefer exposing values through an actual data source, like reading a tag or an SSM parameter the other config wrote, instead of coupling two configs' state files directly.
You can't fully avoid it if the resource itself has a sensitive attribute, Terraform records the full resulting attribute values of every managed resource in state, and marking a variable sensitive only redacts it from CLI output and logs, it does nothing to the state file itself.
The real mitigations are: encrypt the state at rest, S3 with SSE-KMS or Terraform Cloud's built-in encryption, restrict who and what can read the state, tight IAM or workspace permissions, since anyone who can read state can read every secret in it, and where possible avoid putting the secret in Terraform's control at all, generate it in a secrets manager and have Terraform reference it rather than create it. For genuinely sensitive material, a lot of teams have Terraform provision the empty resource, like an RDS instance, and a separate process rotate and manage the actual password afterward, specifically to keep it out of state.
Terraform infers dependencies automatically whenever one resource's argument references another resource's attribute, that covers the vast majority of cases. You need depends_on when there's a dependency that isn't visible in the arguments at all, most often with IAM: a resource might work initially even without its IAM policy attachment resolved because AWS eventual consistency lets it slide, but you actually need the policy attached before that resource starts being used, and there's no attribute reference to express that.
It also comes up with provisioners, since a local-exec block doesn't naturally create graph edges the way resource arguments do. The failure mode of over-using depends_on is it can force unnecessary serialization, two resources that could apply in parallel now wait on each other for no real reason, which slows down large applies.
The common pattern is a directory per environment, each with its own backend configuration and its own tfvars file, all calling the same shared modules from a modules directory or a versioned module registry. So you'd have environments/dev, environments/staging, environments/prod, each a separate root module with its own state, but the actual resource definitions live once in modules/network, modules/app, and so on.
This gives you real isolation, a broken plan in dev can't touch prod's state file, and it lets environments diverge slightly, prod might need an extra module or different instance sizes, without conditional logic scattered through one shared config. The cost is duplication across the environment directories, which is exactly the problem tools like Terragrunt try to solve by generating that boilerplate from a single source, though that adds its own layer of tooling to maintain.
create_before_destroy flips the default destroy-then-create order to create-then-destroy, which matters for anything where a brief gap is unacceptable, like an autoscaling group launch template swap where you don't want zero instances running mid-replacement. prevent_destroy blocks a destroy outright at plan time, useful on things like a production database or an S3 bucket holding backups, where an accidental terraform destroy or a bad refactor shouldn't even be able to succeed.
ignore_changes tells Terraform to stop tracking drift on specific attributes, common when something outside Terraform legitimately mutates a field, like an autoscaling group's desired_count that a scaling policy adjusts at runtime, without it every plan would show a diff fighting against the autoscaler. Combining ignore_changes = all is a blunt instrument that effectively takes the resource out of Terraform's management for anything except its initial creation, which is rarely what people actually want.
First figure out why it's forcing replacement, run terraform plan and look for the specific attribute marked as forcing replacement, some arguments, like an RDS instance's storage engine, simply can't be changed in place by the provider's API, no Terraform setting fixes that. If the resource supports it, create_before_destroy in the lifecycle block gets you a new resource stood up before the old one is torn down, then you need something outside Terraform, like a load balancer or DNS switch, to redirect traffic once the new one is healthy.
If the underlying API actually does support an in-place update but the provider's schema doesn't expose it that way, sometimes the fix is a newer provider version. And if none of that applies, the honest answer is you plan the cutover manually, standing up the replacement in parallel, migrating traffic, and only then letting Terraform remove the old resource, rather than trusting an automatic destroy and create to be safe.
You can't have two provider blocks for the same provider type without aliasing one of them, Terraform needs a way to tell them apart. A provider block aliased "us_east" and a second one aliased "eu_west" let you deploy resources to two regions or two accounts from the same configuration, then each resource picks which one to use with provider = aws.eu_west.
It's the standard pattern for things like a global CloudFront setup where the certificate has to live in us-east-1 regardless of where the rest of your infrastructure runs, or replicating a table into a second region for disaster recovery. Without aliasing you'd need entirely separate Terraform configurations and state files per region or account, which is also a valid approach for larger setups, aliasing is really for cases where the resources are tightly coupled enough that managing them in one config and one state makes more operational sense.
With the classic S3 backend, DynamoDB holds a lock item keyed by the state file's path, Terraform writes that item before it starts an apply and deletes it after, and any other terraform apply or plan that tries to acquire the same key blocks or errors out immediately. Terraform Cloud handles locking as part of its run queue instead, runs are literally serialized per workspace so there's no separate lock table to reason about.
Both approaches have the same failure mode: if a process gets killed hard, someone's laptop dies mid-apply, a CI job gets forcibly cancelled, the lock can be left behind even though nothing is actually running. Terraform surfaces this as an error acquiring the state lock with a lock ID, and the fix is terraform force-unlock, but you want to be genuinely sure nothing else is applying before you do that, force-unlocking while another apply is actually in flight is how you get real state corruption.
Two common approaches, not mutually exclusive. Pass subnet_id in as a variable from whichever module owns the networking, or look it up with a data source filtered by tag, so the same config works across environments without anyone updating a literal ID by hand.
data "aws_subnets" "private" {
filter {
name = "tag:Tier"
values = ["private"]
}
}
resource "aws_instance" "app" {
subnet_id = data.aws_subnets.private.ids[0]
}Hardcoding a subnet ID directly into a resource block is the classic mistake that surfaces once someone reuses the module in a second account or region, and it silently points at nothing, or worse, at the wrong VPC.
Hard questions
12With locking configured, whichever apply grabs the lock first runs normally. The second one fails fast with an "Error acquiring the state lock" message instead of proceeding. Terraform's own documentation puts it plainly: state locking happens automatically on operations that could write state, and it exists specifically to stop a second writer from corrupting the file (Terraform docs, state locking).
Without a lock, the second apply can read state that's about to go stale, compute a plan against it, then write its own "final" state once it finishes, overwriting whatever the first apply just created. Resources the first run made can end up orphaned, nothing in state pointing at them. The classic setup is S3 plus a DynamoDB table for locking; as of Terraform 1.10, S3 can lock natively without one, though plenty of pipelines still carry the old setup forward out of habit.
terraform {
backend "s3" {
bucket = "my-team-tf-state"
key = "prod/network.tfstate"
region = "us-east-1"
use_lockfile = true
}
}count creates N copies of a resource, indexed 0 through N-1, purely by position. for_each creates one resource per key in a map or set of strings, and Terraform tracks each instance by that key, not by position.
count bites you the moment order changes. Delete the second item out of a five-item list and count reindexes everything after it, so items three, four, and five all shift down one slot. Terraform reads that as "these resources changed identity" and destroys and recreates all three, even though nothing changed except their position.
variable "subnet_cidrs" {
default = {
"us-east-1a" = "10.0.1.0/24"
"us-east-1b" = "10.0.2.0/24"
"us-east-1c" = "10.0.3.0/24"
}
}
resource "aws_subnet" "this" {
for_each = var.subnet_cidrs
cidr_block = each.value
availability_zone = each.key
}for_each keyed by availability zone sidesteps that. Remove us-east-1b and only that one subnet gets destroyed, the other two are untouched, because Terraform never re-derives their identity from position.
local-exec runs on whatever's running terraform apply, your laptop or a CI runner. remote-exec connects into the resource Terraform just created and runs there instead.
The failure mode is the sharp part. If remote-exec fails partway through, after the VM already exists and is running fine, Terraform marks the whole resource creation as failed, and it gets tainted. Next apply, Terraform destroys and recreates it, even though the actual infrastructure was healthy and only some setup script inside it choked.
terraform import resource_address real_world_id adds an existing cloud resource into Terraform's state, so Terraform starts tracking and managing something that was created outside of Terraform entirely, a database someone clicked into existence in the console years ago, say.
terraform import aws_db_instance.legacy mydb-prod-2019What it deliberately does not do: generate the HCL configuration for you. The docs are explicit that importing via the CLI does not generate configuration (Terraform docs, import). You still have to hand-write a resource block matching what got imported, and if it doesn't match closely enough, the next plan tries to "correct" the real resource to match your wrong config, which can mean deleting something that was working fine. Newer versions added an import block plus terraform plan -generate-config-out, which auto-drafts a starting HCL block, cutting down on that trap without eliminating it.
First Terraform loads the configuration and builds a resource graph from all the references between blocks. Then, unless you've disabled it, it refreshes state, for every resource already in state it calls the provider's read function to fetch the current real-world values and reconciles that against what's recorded, this is how it catches things changed outside Terraform.
With an up to date picture of both desired config and real state, it walks the graph and for each resource calls the provider's plan and diff logic to compute what would need to change, add, update in place, or destroy and recreate. All of that gets assembled into the plan output you see, and if you save it with -out, that exact plan, not just a description of it, an actual serialized set of planned changes, gets written to a file so a later apply can execute precisely that plan rather than recomputing it, which matters because state can genuinely change between plan and apply in a live environment.
During the refresh step, Terraform calls the provider's read function for that resource, gets back not found, and marks it as needing to be recreated, so the next plan shows it as a create rather than a no-op, which can be alarming if you weren't expecting it and the resource had dependents that also get swept into the plan. In older Terraform versions this could silently just recreate it with no warning, current versions explicitly annotate it in the plan output as detected drift so at least you see it coming.
Your real options are: let Terraform recreate it if that's actually fine, stateless resources, nothing depends on its identity, or if the deletion was intentional and you don't want it recreated, remove it from state with terraform state rm so Terraform simply forgets about it. What you don't want to do is edit the state file by hand to fake a resource back into existence, that only works if you also run terraform import against something real, otherwise you've just got state that doesn't correspond to anything and every future operation on it will fail.
The core tool is terraform state mv, which moves a resource's entry from one state file to another without touching the real infrastructure at all, it's purely a bookkeeping operation. The practical process is: write the new, smaller root module with its own backend configuration pointing at a fresh state location, then for each resource that should live there, run state mv against both the source and destination configs to relocate its entry, then remove that resource's block from the old config's source files.
It's slow and resource by resource, and any resource that other resources still depend on across the split needs to be exposed as an output from the new module and consumed via terraform_remote_state or similar wiring in the old one, otherwise you'll get missing reference errors. The genuinely dangerous part is doing this on a live production state without a state backup first, terraform state pull piped to a file before you start, because a partially completed split with mismatched configs and state can leave resources orphaned in neither location.
A moved block, introduced in Terraform 1.1, records a resource's rename or relocation, say you renamed aws_instance.web to aws_instance.app_server, or moved a resource into a module, directly in the configuration itself. When Terraform sees that block, it treats the state entry for the old address as if it belongs to the new address, so the plan comes back clean, no destroy and recreate, even though the resource name changed in code.
The advantage over terraform state mv is that it's declarative and committed to version control, every teammate who pulls the branch and runs plan gets the same result automatically, whereas state mv is a one time imperative command someone has to remember to run against every copy of that state, easy to miss in a team with multiple environments or when someone forgets to run it before merging. The tradeoff is moved blocks accumulate in the codebase over time as a permanent record of every rename, which some teams clean up periodically once they're confident every environment has applied through them.
Lineage is a UUID generated once, the first time a state file is created, meant to uniquely identify this specific chain of state history for as long as that infrastructure exists. Serial is a counter that increments on every single write to state, used to detect whether two copies of state have diverged, if you have two state files with the same lineage but different serials, Terraform knows one is simply older than the other.
A lineage mismatch means you're looking at two state files that don't share a common origin at all, which happens most often when someone runs terraform init against a fresh backend that already has an unrelated state file sitting in it, or restores a backup from a completely different environment into the wrong backend path. Terraform refuses to just merge or silently pick one, it errors out specifically because blindly proceeding could mean applying changes computed against one infrastructure's history onto a different infrastructure's real resources, which is exactly the kind of mismatch that leads to accidentally destroying the wrong environment.
Providers run as separate plugin processes that Terraform core talks to over an internal RPC protocol, originally net/rpc, now gRPC, and that error means the provider process itself died or the connection to it dropped mid-operation, it's not a normal API call failure, it's Terraform losing contact with its own plugin. Common causes are the provider process running out of memory on a huge apply, thousands of resources, a for_each over a very large map, a bug in the provider itself causing a panic, or in CI, the container getting OOM killed and taking the provider process down with it.
The first move is setting TF_LOG=trace and rerunning, which shows you the actual RPC calls and where the last successful one happened before the crash, that tells you which resource or provider call was in flight. Beyond that, check for a known issue on the provider's version, in practice a lot of these traces back to a specific buggy provider release, and check whether the operation genuinely needs more memory or a smaller batch size, since splitting an enormous for_each into smaller applies is a real, if unsatisfying, workaround while waiting on a provider fix.
Terraform builds a directed graph from every attribute reference, and if it finds a genuine cycle it fails immediately at plan time with an explicit cycle error naming the resources involved, it won't attempt to guess an order. In practice a true cycle where each resource's argument needs a value only the other one produces is rare because it's usually a sign the two things shouldn't be a single resource pair in the first place.
More often what looks like a cycle is actually resolvable by splitting one side into two resources, for example a security group and a security group rule as separate resources instead of both referencing each other inline, which breaks the cycle because the rule resource can depend on the group without the group needing anything back from the rule. The other common real cause is an accidental self-reference through a data source, a data source reading a resource that itself depends on that same data source's output, which reads as a cycle even though it's arguably a config mistake more than an inherent architectural need for circularity.
for_each uses the map's keys, not its values or index position, as each resource instance's stable identity, so renaming a key is functionally identical in Terraform's eyes to deleting the resource under the old key and creating a brand new one under the new key, even if every other attribute is byte for byte identical. That means a plan showing what looks like a harmless rename actually shows a destroy and a create, and for something like an RDS instance or an EBS volume that's a real, disruptive, possibly irreversible operation, not a relabeling.
This is the sharp edge that catches people who generate their for_each map dynamically from something like a database query or an external API response, if that source ever reorders or renames without you noticing, your next apply can quietly destroy production resources under a pretext that looks cosmetic in a diff. The mitigation is either to make the key generating logic provably stable, derive keys from something immutable, not a mutable label, or when a genuine rename is intended, use a moved block to tell Terraform it's the same resource under a new for_each key instead of letting it infer a destroy and recreate.
How to prepare for a Terraform interview in 2026
Terraform interview questions in 2026 reward hands-on breakage more than passive reading, so skip memorizing every argument on every resource type, nobody expects the AWS provider's changelog memorized, and reaching for the docs mid-interview if you genuinely forget an argument name usually isn't penalized. Build one small multi-environment setup instead: a network module, a compute module calling it, a remote backend with locking, then deliberately break something. Delete a subnet from a count-based list and watch what gets destroyed that shouldn't. Run two applies at once and read the lock error. Import something by hand and watch the next plan try to "fix" it because your config didn't quite match.
Across mock interview sessions tagged DevOps and platform engineering on LastRoundAI in the first half of 2026, state and drift questions caught more candidates than any single HCL syntax question did. Candidates who could write a clean module from scratch would still stumble the moment a follow-up asked what happens when state and reality disagree. We don't have a clean percentage for that, only that it's the pattern reviewers keep flagging session after session.
Cloud and DevOps roles aren't shrinking either. The BLS groups this work under its broader software developer, QA analyst, and tester category, projected to grow 15 percent through 2034, well above average for all occupations (BLS Occupational Outlook Handbook). Whether that growth favors Terraform over whatever HashiCorp or a competitor ships next is a separate argument, and honestly, one I don't have a confident answer to.
Get the reps in before the real thing
Reading an answer about state locking isn't the same as defending it live once an interviewer changes one variable on you mid-question. LastRoundAI's mock interview mode runs DevOps and platform engineering rounds with real-time follow-ups instead of a static question bank, and the free plan includes 15 credits a month that reset monthly rather than stockpiling. Starter is $19 a month if a handful of sessions doesn't cover what you need.
If the slower part right now is finding enough DevOps, SRE, or platform engineering roles to apply to, rather than passing the interview once you land one, Auto-Apply matches and applies to roles for you, 10 a month on the free plan, up to 400 on Ultimate, with every application held in a review queue until you approve it. Nothing goes out under your name without you looking at it first.
Questions about either product go to contact@lastroundai.com. That's the only inbox we check.
How this list was built
Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.
What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.
If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.
Frequently asked questions
How long does it take to prepare for a Terraform interview?
If you already work with Terraform day to day, a focused week on the areas you avoid in practice is usually enough. Coming in cold, expect three to four weeks. The gap is rarely knowledge; it is being able to explain something you normally just use.
What Terraform topics come up most often?
Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.
Do I need hands-on Terraform experience to pass?
It shows quickly either way. Textbook answers hold up until the interviewer asks what you did when it broke, and that is usually the question that separates candidates. A small real project you can discuss honestly beats a longer list of familiarity claims.
Is Terraform still worth learning in 2026?
For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.

