In this post we are going to cover count, for_each and dynamic blocks in Terraform. These are 7 real Terraform interview questions for data engineers, asked the way they come up on a real project, and the last one is a scenario: each new data domain needs a container, a group and permissions, driven by one config file, and you build it.
count and for_each create many instances from one resource block. dynamic blocks repeat nested blocks, and for expressions reshape the inputs. On a data platform we use them everywhere: containers, domains, role assignments, network rules. Interviewers ask about them because the wrong choice can turn a one-line list edit into deleted containers.
Part 5 of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT.
- The short answer
- Let's set up the project
- Question 1: A teammate created five containers with count, removed the second one, and the plan wants to recreate three. Why?
- Question 2: Your manager asks when to use count and when to use for_each. What is your rule?
- Question 3: The plan fails with an error that for_each values are not known until apply. What does it mean, and how do you fix it?
- Question 4: A network rule block must repeat for every IP range in a list. How do dynamic blocks work?
- Question 5: Someone wants to build a map of settings from a list of objects. How do for expressions and functions help?
- Question 6: A teammate needs one resource only in production. How do you create a resource conditionally?
- Scenario 7: Each new data domain needs a container, a group and permissions, driven by one config file. How do you build it?
- The full script
The short answer
Use for_each with a map or a set for named things, so each instance has a stable key; keep count for identical copies and 0-or-1 switches. The keys must be known at plan. dynamic blocks repeat nested blocks, for expressions reshape inputs, and one YAML file plus for_each onboards a new data domain without new code.
Let's set up the project
We use a small Terraform 1.17 project. local_file stands in for a storage container, and terraform_data stands in for groups, role assignments and other resources, so every plan is real and nothing is created in Azure. The network/ folder is real azurerm 5.11 code, checked with terraform validate and terraform test. Before the first step we run terraform init and terraform apply in the root folder, so the five containers exist. We also run init in pipeline/, network/ (with -backend=false), settings/, alerts/ and domains/, and apply domains/.
terraform {
required_version = ">= 1.17"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
}
}variable "containers" {
type = list(string)
}
# stand-in for azurerm_storage_container: the name forces replacement
resource "local_file" "container" {
count = length(var.containers)
filename = "${path.module}/lake/${var.containers[count.index]}.keep"
content = var.containers[count.index]
}containers = ["raw", "bronze", "silver", "gold", "sandbox"]terraform {
required_version = ">= 1.17"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
}resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
locals {
sfx = random_string.suffix.result
}
# the KEYS depend on a value known only after apply
resource "terraform_data" "container" {
for_each = toset(["raw-${local.sfx}", "gold-${local.sfx}"])
input = each.key
}terraform {
required_version = ">= 1.17"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.11"
}
}
}provider "azurerm" {
features {}
subscription_id = var.subscription_id
}variable "subscription_id" {
type = string
}
variable "ingress_ranges" {
type = map(object({
cidr = string
priority = number
}))
default = {
office = { cidr = "203.0.113.0/24", priority = 100 }
partner = { cidr = "198.51.100.0/24", priority = 110 }
vpn = { cidr = "192.0.2.0/24", priority = 120 }
}
}resource "azurerm_linux_web_app" "api" {
name = "app-sales-data-api-dev"
location = "eastus2"
resource_group_name = "rg-sales-data-dev"
service_plan_id = azurerm_service_plan.api.id
site_config {
ip_restriction_default_action = "Deny"
dynamic "ip_restriction" {
for_each = var.ingress_ranges
content {
name = "allow-${ip_restriction.key}"
ip_address = ip_restriction.value.cidr
priority = ip_restriction.value.priority
action = "Allow"
}
}
}
}resource "azurerm_service_plan" "api" {
name = "asp-sales-data-dev"
location = "eastus2"
resource_group_name = "rg-sales-data-dev"
os_type = "Linux"
sku_name = "B1"
}mock_provider "azurerm" {}
variables {
subscription_id = "00000000-0000-0000-0000-000000000000"
}
run "one_rule_per_range" {
command = plan
assert {
condition = length(
azurerm_linux_web_app.api.site_config[0].ip_restriction
) == 3
error_message = "Expected one IP restriction per range."
}
}variable "domains" {
type = list(object({
name = string
owner = string
retention = number
}))
default = [
{ name = "sales", owner = "sales-eng", retention = 30 },
{ name = "finance", owner = "fin-eng", retention = 90 },
{ name = "crm", owner = "sales-eng", retention = 30 },
]
}
locals {
by_name = { for d in var.domains : d.name => d }
}
resource "terraform_data" "retention" {
for_each = local.by_name
input = "${each.key}: ${each.value.retention} days"
}variable "environment" {
type = string
default = "dev"
}
# only production gets an alert action group
resource "terraform_data" "alerts" {
count = var.environment == "prod" ? 1 : 0
input = "ag-sales-data-${var.environment}"
}
output "alert_group" {
value = terraform_data.alerts[0].input
}domains:
sales:
owner: sales-data-team
readers: [finance]
finance:
owner: finance-data-team
readers: []locals {
domains = yamldecode(file("${path.module}/domains.yaml")).domains
# one entry per reader and domain, e.g. "finance/sales"
readers = merge([
for n, d in local.domains : {
for r in d.readers : "${r}/${n}" => { reader = r, domain = n }
}
]...)
}# stand-ins for azurerm_storage_container, an Entra ID group
# and azurerm_role_assignment: the for_each keys are the same
resource "terraform_data" "container" {
for_each = local.domains
input = each.key
}
resource "terraform_data" "group" {
for_each = local.domains
input = "grp-${each.key}-data"
}
resource "terraform_data" "writer" {
for_each = local.domains
input = {
role = "Storage Blob Data Contributor"
group = terraform_data.group[each.key].input
scope = terraform_data.container[each.key].input
}
}
resource "terraform_data" "reader" {
for_each = local.readers
input = {
role = "Storage Blob Data Reader"
group = terraform_data.group[each.value.reader].input
scope = terraform_data.container[each.value.domain].input
}
}Question 1: A teammate created five containers with count, removed the second one, and the plan wants to recreate three. Why?
Because count identifies each instance by its position, not its name. Five containers come from one list, and the state holds them as index 0 to 4. Let's check with terraform state list.
$ terraform state list
local_file.container[0]
local_file.container[1]
local_file.container[2]
local_file.container[3]
local_file.container[4]We see local_file.container[0] to local_file.container[4].
Now let's remove bronze, the second item, and run terraform plan -refresh=false. Every name after it moves up one place. Index 1 was bronze and is now silver, index 2 was silver and is now gold, and so on.
containers = ["raw", "silver", "gold", "sandbox"]$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy
-/+ destroy and then create replacement
Terraform will perform the following actions:
# local_file.container[1] must be replaced
-/+ resource "local_file" "container" {
~ content = "bronze" -> "silver" # forces replacement
~ content_base64sha256 = "/LvNWlm/SKGOcZ4nure2+GNJQAsfPzpkp3cwWMYF2K0=" -> (known after apply)
~ content_base64sha512 =
"p5J46a1wa4KzU9Pe27F4qBoVnU/y9IhlE2+Chf3IY6scagyEd2E7omHVqPkOWqPVpQ5U0MKxGHVCGhViRPFVkw==" ->
(known after apply)
~ content_md5 = "cab7e764b30007514145eaf2f6a7ed0f" -> (known after apply)
~ content_sha1 = "1573f3c1ba13328987ecd1bbf6330416ade449cc" -> (known after apply)
~ content_sha256 = "fcbbcd5a59bf48a18e719e27bab7b6f86349400b1f3f3a64a7773058c605d8ad" ->
(known after apply)
~ content_sha512 =
"a79278e9ad706b82b353d3dedbb178a81a159d4ff2f48865136f8285fdc863ab1c6a0c8477613ba261d5a8f90e5aa3d
5a50e54d0c2b11875421a156244f15593" -> (known after apply)
~ filename = "./lake/bronze.keep" -> "./lake/silver.keep" # forces replacement
~ id = "1573f3c1ba13328987ecd1bbf6330416ade449cc" -> (known after apply)
# (2 unchanged attributes hidden)
}
# local_file.container[2] must be replaced
-/+ resource "local_file" "container" {
~ content = "silver" -> "gold" # forces replacement
~ content_base64sha256 = "eM3mTD5H8sv9nach9Uqs3jN3mRZoPHnehpYomP7vrCE=" -> (known after apply)
~ content_base64sha512 =
"VgAp7vYcwJ16jnwiga9PiiHsK9aJZai/9YXakDSX5dblTt7kByHfKT8p7v2Do1wresc8MGcRHMBJUMICpe3RMA==" ->
(known after apply)
~ content_md5 = "97f014516561ef487ec368d6158eb3f4" -> (known after apply)
~ content_sha1 = "f8248e12727710c946f73d8f6e02eb93530dd9de" -> (known after apply)
~ content_sha256 = "78cde64c3e47f2cbfd9da721f54aacde33779916683c79de86962898feefac21" ->
(known after apply)
~ content_sha512 =
"560029eef61cc09d7a8e7c2281af4f8a21ec2bd68965a8bff585da903497e5d6e54edee40721df293f29eefd83a35c2
b7ac73c3067111cc04950c202a5edd130" -> (known after apply)
~ filename = "./lake/silver.keep" -> "./lake/gold.keep" # forces replacement
~ id = "f8248e12727710c946f73d8f6e02eb93530dd9de" -> (known after apply)
# (2 unchanged attributes hidden)
}
# local_file.container[3] must be replaced
-/+ resource "local_file" "container" {
~ content = "gold" -> "sandbox" # forces replacement
~ content_base64sha256 = "JNfwPY3Dw2Zpaeb6W7H6xHNtPxNTwoMH7VGzIPncQtM=" -> (known after apply)
~ content_base64sha512 =
"uId7v1ENh+bA6aLPbqwTffhwX6I62iTNGRzoIex7a8rjqLKHNqZGFOVxa9lDJ08VxXHIhsUJd2sy1dDhR+0DPQ==" ->
(known after apply)
~ content_md5 = "e07e81c20cf5935f5225765f0af81755" -> (known after apply)
~ content_sha1 = "09feb137fd5e58ac9131bff6c66ba2e70260bf8c" -> (known after apply)
~ content_sha256 = "24d7f03d8dc3c3666969e6fa5bb1fac4736d3f1353c28307ed51b320f9dc42d3" ->
(known after apply)
~ content_sha512 =
"b8877bbf510d87e6c0e9a2cf6eac137df8705fa23ada24cd191ce821ec7b6bcae3a8b28736a64614e5716bd943274f1
5c571c886c509776b32d5d0e147ed033d" -> (known after apply)
~ filename = "./lake/gold.keep" -> "./lake/sandbox.keep" # forces replacement
~ id = "09feb137fd5e58ac9131bff6c66ba2e70260bf8c" -> (known after apply)
# (2 unchanged attributes hidden)
}
# local_file.container[4] will be destroyed
# (because index [4] is out of range for count)
- resource "local_file" "container" {
- content = "sandbox" -> null
- content_base64sha256 = "t61WdHfIN1aquaVCsr4E93264lEV2F8iBw102MxHedw=" -> null
- content_base64sha512 =
"Llfcz4uNbHBRHZJlk6ugBuYFd4MTLHv48IslNpJLmZY3h2Skt4KYtQjR2kiRsN4rlcgNUHv7wP1aO2pwfdzF8A==" ->
null
- content_md5 = "93bc63e0b4f48fbbff568d9fc0dc3def" -> null
- content_sha1 = "9ed037b84943c4caa3a520e48a5540181c46c98c" -> null
- content_sha256 = "b7ad567477c83756aab9a542b2be04f77dbae25115d85f22070d74d8cc4779dc" ->
null
- content_sha512 =
"2e57dccf8b8d6c70511d926593aba006e6057783132c7bf8f08b2536924b9996378764a4b78298b508d1da4891b0de2
b95c80d507bfbc0fd5a3b6a707ddcc5f0" -> null
- directory_permission = "0777" -> null
- file_permission = "0777" -> null
- filename = "./lake/sandbox.keep" -> null
- id = "9ed037b84943c4caa3a520e48a5540181c46c98c" -> null
}
Plan: 3 to add, 0 to change, 4 to destroy.
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.The name forces replacement, like a real Azure storage container name: "bronze" -> "silver" # forces replacement. So the plan replaces indexes 1, 2 and 3, and destroys index 4 (because index [4] is out of range for count). That is Plan: 3 to add, 0 to change, 4 to destroy., for a change that should remove one container.
In Azure, replacing a container means deleting it with its data, and creating an empty one. That's how a small list edit turns into an incident.
I read every "must be replaced" line before I approve. The fix is for_each with stable keys, and that's the next question.
Question 2: Your manager asks when to use count and when to use for_each. What is your rule?
My rule: for_each for anything with a name, like containers, domains or role assignments. count only for identical copies, or for a 0 or 1 switch. With for_each, the key is the identity, so the order of the list doesn't matter.
| count | for_each | |
|---|---|---|
| Instance address | local_file.container[1] | local_file.container["bronze"] |
| Remove one item | later items shift and change | only that item is destroyed |
| Use it for | identical copies, a 0 or 1 switch | anything with a name |
The first try often fails. Let's put the list back with bronze, set for_each = var.containers, and run terraform plan.
containers = ["raw", "bronze", "silver", "gold", "sandbox"]variable "containers" {
type = list(string)
}
# stand-in for azurerm_storage_container: the name forces replacement
resource "local_file" "container" {
for_each = var.containers
filename = "${path.module}/lake/${each.key}.keep"
content = each.key
}$ terraform plan
Error: Invalid for_each argument
on main.tf line 7, in resource "local_file" "container":
7: for_each = var.containers
├────────────────
│ var.containers is a list of string
The given "for_each" argument value is unsuitable: the "for_each" argument
must be a map, or set of strings, and you have provided a value of type list
of string.for_each takes a map or a set of strings, and our variable is a list of string, so the plan stops: Error: Invalid for_each argument. We wrap it in toset().
Existing resources don't have to be recreated. In moved.tf, moved blocks map each old index to its new key: index 0 to raw, index 1 to bronze, and so on. We plan again.
variable "containers" {
type = list(string)
}
# stand-in for azurerm_storage_container: the name forces replacement
resource "local_file" "container" {
for_each = toset(var.containers)
filename = "${path.module}/lake/${each.key}.keep"
content = each.key
}moved {
from = local_file.container[0]
to = local_file.container["raw"]
}
moved {
from = local_file.container[1]
to = local_file.container["bronze"]
}
moved {
from = local_file.container[2]
to = local_file.container["silver"]
}
moved {
from = local_file.container[3]
to = local_file.container["gold"]
}
moved {
from = local_file.container[4]
to = local_file.container["sandbox"]
}$ terraform plan -refresh=false
Terraform will perform the following actions:
# local_file.container[1] has moved to local_file.container["bronze"]
resource "local_file" "container" {
id = "1573f3c1ba13328987ecd1bbf6330416ade449cc"
# (10 unchanged attributes hidden)
}
# local_file.container[3] has moved to local_file.container["gold"]
resource "local_file" "container" {
id = "09feb137fd5e58ac9131bff6c66ba2e70260bf8c"
# (10 unchanged attributes hidden)
}
# local_file.container[0] has moved to local_file.container["raw"]
resource "local_file" "container" {
id = "ce15802a8c5e8e9db0ffaf10130ef265296e9ea4"
# (10 unchanged attributes hidden)
}
# local_file.container[4] has moved to local_file.container["sandbox"]
resource "local_file" "container" {
id = "9ed037b84943c4caa3a520e48a5540181c46c98c"
# (10 unchanged attributes hidden)
}
# local_file.container[2] has moved to local_file.container["silver"]
resource "local_file" "container" {
id = "f8248e12727710c946f73d8f6e02eb93530dd9de"
# (10 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.The plan shows five has moved to lines, and Plan: 0 to add, 0 to change, 0 to destroy. Let's apply the moves.
$ terraform apply -auto-approve -refresh=false
Terraform will perform the following actions:
# local_file.container[1] has moved to local_file.container["bronze"]
resource "local_file" "container" {
id = "1573f3c1ba13328987ecd1bbf6330416ade449cc"
# (10 unchanged attributes hidden)
}
# local_file.container[3] has moved to local_file.container["gold"]
resource "local_file" "container" {
id = "09feb137fd5e58ac9131bff6c66ba2e70260bf8c"
# (10 unchanged attributes hidden)
}
# local_file.container[0] has moved to local_file.container["raw"]
resource "local_file" "container" {
id = "ce15802a8c5e8e9db0ffaf10130ef265296e9ea4"
# (10 unchanged attributes hidden)
}
# local_file.container[4] has moved to local_file.container["sandbox"]
resource "local_file" "container" {
id = "9ed037b84943c4caa3a520e48a5540181c46c98c"
# (10 unchanged attributes hidden)
}
# local_file.container[2] has moved to local_file.container["silver"]
resource "local_file" "container" {
id = "f8248e12727710c946f73d8f6e02eb93530dd9de"
# (10 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.Now we remove bronze again, and plan.
containers = ["raw", "silver", "gold", "sandbox"]$ terraform plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# local_file.container["bronze"] will be destroyed
# (because key ["bronze"] is not in for_each map)
- resource "local_file" "container" {
- content = "bronze" -> null
- content_base64sha256 = "/LvNWlm/SKGOcZ4nure2+GNJQAsfPzpkp3cwWMYF2K0=" -> null
- content_base64sha512 =
"p5J46a1wa4KzU9Pe27F4qBoVnU/y9IhlE2+Chf3IY6scagyEd2E7omHVqPkOWqPVpQ5U0MKxGHVCGhViRPFVkw==" ->
null
- content_md5 = "cab7e764b30007514145eaf2f6a7ed0f" -> null
- content_sha1 = "1573f3c1ba13328987ecd1bbf6330416ade449cc" -> null
- content_sha256 = "fcbbcd5a59bf48a18e719e27bab7b6f86349400b1f3f3a64a7773058c605d8ad" ->
null
- content_sha512 =
"a79278e9ad706b82b353d3dedbb178a81a159d4ff2f48865136f8285fdc863ab1c6a0c8477613ba261d5a8f90e5aa3d
5a50e54d0c2b11875421a156244f15593" -> null
- directory_permission = "0777" -> null
- file_permission = "0777" -> null
- filename = "./lake/bronze.keep" -> null
- id = "1573f3c1ba13328987ecd1bbf6330416ade449cc" -> null
}
Plan: 0 to add, 0 to change, 1 to destroy.
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.Only local_file.container["bronze"] is destroyed, (because key ["bronze"] is not in for_each map). Nothing else moves: 0 to add, 0 to change, 1 to destroy.
Pick keys you will never rename. A new key is a new resource, so a rename needs a moved block too.
Question 3: The plan fails with an error that for_each values are not known until apply. What does it mean, and how do you fix it?
It means the keys of for_each must be known when Terraform plans. In pipeline/main.tf, the container names include a random suffix, and the suffix only exists after apply. Let's run terraform -chdir=pipeline plan.
$ terraform -chdir=pipeline plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform planned the following actions, but then encountered a problem:
# random_string.suffix will be created
+ resource "random_string" "suffix" {
+ id = (known after apply)
+ length = 6
+ lower = true
+ min_lower = 0
+ min_numeric = 0
+ min_special = 0
+ min_upper = 0
+ number = true
+ numeric = true
+ result = (known after apply)
+ special = false
+ upper = false
}
Plan: 1 to add, 0 to change, 0 to destroy.
Error: Invalid for_each argument
on main.tf line 13, in resource "terraform_data" "container":
13: for_each = toset(["raw-${local.sfx}", "gold-${local.sfx}"])
├────────────────
│ local.sfx is a string, known only after apply
The "for_each" set includes values derived from resource attributes that
cannot be determined until apply, and so Terraform cannot determine the full
set of keys that will identify the instances of this resource.
When working with unknown values in for_each, it's better to use a map value
where the keys are defined statically in your configuration and where only
the values contain apply-time results.
Alternatively, you could use the -target planning option to first apply only
the resources that the for_each value depends on, and then apply a second
time to fully converge.Terraform plans the suffix, and then stops: Error: Invalid for_each argument, with local.sfx is a string, known only after apply. Without the keys, it can't name the instances.
The error message gives the fix: keys defined statically in the configuration, and only the values from apply. So raw and gold are now the keys, and the suffix goes into the value. We plan again.
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
locals {
sfx = random_string.suffix.result
}
# static keys; only the VALUES are known after apply
resource "terraform_data" "container" {
for_each = toset(["raw", "gold"])
input = "${each.key}-${local.sfx}"
}$ terraform -chdir=pipeline plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# random_string.suffix will be created
+ resource "random_string" "suffix" {
+ id = (known after apply)
+ length = 6
+ lower = true
+ min_lower = 0
+ min_numeric = 0
+ min_special = 0
+ min_upper = 0
+ number = true
+ numeric = true
+ result = (known after apply)
+ special = false
+ upper = false
}
# terraform_data.container["gold"] will be created
+ resource "terraform_data" "container" {
+ id = (known after apply)
+ input = (known after apply)
+ output = (known after apply)
}
# terraform_data.container["raw"] will be created
+ resource "terraform_data" "container" {
+ id = (known after apply)
+ input = (known after apply)
+ output = (known after apply)
}
Plan: 3 to add, 0 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.The plan works: terraform_data.container["gold"] and terraform_data.container["raw"] will be created.
The real case is for_each over IDs created in the same run, like principal IDs. Key the map by a name you set in code, and put the ID in the value.
The error also suggests -target. That's a one-off escape hatch, not a pipeline step. In Terraform 1.17, unknown keys are still an error, so design the keys up front.
Question 4: A network rule block must repeat for every IP range in a list. How do dynamic blocks work?
A dynamic block generates nested blocks from a collection, instead of copy-pasting them. In network/main.tf we create one ip_restriction for each allowed range, inside the site_config of an azurerm_linux_web_app.
Let's read it step by step:
for_eachtakes the map of ranges,var.ingress_ranges.- Inside
content, the iterator is named after the block label. ip_restriction.keyis office, partner or vpn.ip_restriction.valueholds thecidrand thepriority.
We can't plan against Azure here, so we prove it twice. Validate checks it against the real azurerm 5.11 schema, and terraform test plans it with a mock provider and asserts three rules. We run terraform -chdir=network validate and terraform -chdir=network test.
$ terraform -chdir=network validate
Success! The configuration is valid.
$ terraform -chdir=network test
tests/api.tftest.hcl... in progress
run "one_rule_per_range"... pass
tests/api.tftest.hcl... tearing down
tests/api.tftest.hcl... pass
Success! 1 passed, 0 failed.Validate says success, and the test says Success! 1 passed, 0 failed.
The pitfall: dynamic repeats blocks, never plain arguments. On a storage account, network_rules.ip_rules is a set argument: just assign the variable. NSG security_rule is an attribute in block mode: dynamic works, but the docs prefer a for expression, so an empty list clears old rules.
If a block appears once or twice, I write it out: readable beats clever.
Question 5: Someone wants to build a map of settings from a list of objects. How do for expressions and functions help?
A for expression turns one collection into another. Curly braces build a map, square brackets build a list. In settings/main.tf, domains is a list of objects, each with a name, an owner and a retention in days. Let's try two expressions in terraform console.
$ echo '{ for d in var.domains : d.name => d.retention }' | terraform -chdir=settings console
{
"crm" = 30
"finance" = 90
"sales" = 30
}
$ echo '[for d in var.domains : d.name if d.retention > 60]' | terraform -chdir=settings console
[
"finance",
]for d in var.domains : d.name => d.retention gives a map keyed by name. Add an if clause and it filters: only "finance" keeps data longer than 60 days.
Map keys must be unique. Let's key by owner instead.
$ echo '{ for d in var.domains : d.owner => d.name }' | terraform -chdir=settings console
Error: Duplicate object key
on <console-input> line 1:
(source code not available)
Two different items produced the key "sales-eng" in this 'for' expression. If
duplicates are expected, use the ellipsis (...) after the value expression to
enable grouping by key.sales-eng appears twice: Error: Duplicate object key. The error names the fix: the ellipsis, three dots after the value, groups the names into a list per owner.
$ echo '{ for d in var.domains : d.owner => d.name... }' | terraform -chdir=settings console
{
"fin-eng" = [
"finance",
]
"sales-eng" = [
"sales",
"crm",
]
}Now "sales-eng" holds "sales" and "crm". In a real project, we build the map once, in a local keyed by name (local.by_name), and feed it to for_each. We run terraform -chdir=settings plan.
$ terraform -chdir=settings plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# terraform_data.retention["crm"] will be created
+ resource "terraform_data" "retention" {
+ id = (known after apply)
+ input = "crm: 30 days"
+ output = (known after apply)
}
# terraform_data.retention["finance"] will be created
+ resource "terraform_data" "retention" {
+ id = (known after apply)
+ input = "finance: 90 days"
+ output = (known after apply)
}
# terraform_data.retention["sales"] will be created
+ resource "terraform_data" "retention" {
+ id = (known after apply)
+ input = "sales: 30 days"
+ output = (known after apply)
}
Plan: 3 to add, 0 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.The plan shows retention["crm"], retention["finance"] and retention["sales"], each with its own setting.
Other functions I use every week:
merge()- defaults, the last map winslookup()- a key with a fallbacktry()- optional fieldsflatten()- nested lists into pairs
Question 6: A teammate needs one resource only in production. How do you create a resource conditionally?
Count with a condition: if environment is prod, 1, otherwise 0. In dev, the resource simply doesn't exist.
The trap is the reference. alerts/main.tf outputs terraform_data.alerts[0].input. That works in prod, but the default environment is dev. Let's run terraform -chdir=alerts plan.
$ terraform -chdir=alerts plan
Planning failed. Terraform encountered an error while generating this plan.
Error: Invalid index
on main.tf line 13, in output "alert_group":
13: value = terraform_data.alerts[0].input
├────────────────
│ terraform_data.alerts is empty tuple
The given key does not identify an element in this collection value: the
collection has no elements.In dev there are no instances, so the plan fails: Error: Invalid index, with terraform_data.alerts is empty tuple.
We use one() with a splat instead: one(terraform_data.alerts[*].input). It returns null for zero instances and the value for one. We plan for dev, then with -var environment=prod.
variable "environment" {
type = string
default = "dev"
}
# only production gets an alert action group
resource "terraform_data" "alerts" {
count = var.environment == "prod" ? 1 : 0
input = "ag-sales-data-${var.environment}"
}
output "alert_group" {
value = one(terraform_data.alerts[*].input)
}$ terraform -chdir=alerts plan
No changes. Your infrastructure matches the configuration.
Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.
$ terraform -chdir=alerts plan -var environment=prod
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# terraform_data.alerts[0] will be created
+ resource "terraform_data" "alerts" {
+ id = (known after apply)
+ input = "ag-sales-data-prod"
+ output = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ alert_group = "ag-sales-data-prod"
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.Dev shows No changes. With environment prod, terraform_data.alerts[0] will be created, and the output is set: alert_group = "ag-sales-data-prod".
for_each works too, with a set that is empty or has one item. Let's try it.
variable "environment" {
type = string
default = "dev"
}
# only production gets an alert action group
resource "terraform_data" "alerts" {
for_each = var.environment == "prod" ? toset(["prod"]) : toset([])
input = "ag-sales-data-${each.key}"
}
output "alert_group" {
value = one([for a in terraform_data.alerts : a.input])
}$ terraform -chdir=alerts plan -var environment=prod
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# terraform_data.alerts["prod"] will be created
+ resource "terraform_data" "alerts" {
+ id = (known after apply)
+ input = "ag-sales-data-prod"
+ output = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ alert_group = "ag-sales-data-prod"
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.Now the instance has a name, terraform_data.alerts["prod"], instead of index 0, which reads better in the plan.
A clear feature switch, like alerts_enabled = true in the prod tfvars file, reads better than environment checks spread across many files.
Scenario 7: Each new data domain needs a container, a group and permissions, driven by one config file. How do you build it?
Make it data-driven. One YAML file, domains/domains.yaml, lists the domains, with an owner and the other domains that may read them. Terraform reads it with yamldecode(), and every resource uses for_each over the domains, keyed by the domain name.
Permissions are pairs, so a for expression in domains/locals.tf flattens them: one entry per reader and domain, like finance/sales. Each domain's group gets Storage Blob Data Contributor on its own container, and readers get Storage Blob Data Reader.
Here these are stand-ins. In Azure, they map to:
- container -
azurerm_storage_container - group - an Entra ID group
- permissions -
azurerm_role_assignment, with the same keys
Onboarding is now one pull request with three lines of YAML. Let's add marketing, readable by sales, and run terraform -chdir=domains plan -refresh=false.
domains:
sales:
owner: sales-data-team
readers: [finance]
finance:
owner: finance-data-team
readers: []
marketing:
owner: marketing-data-team
readers: [sales]$ terraform -chdir=domains plan -refresh=false
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# terraform_data.container["marketing"] will be created
+ resource "terraform_data" "container" {
+ id = (known after apply)
+ input = "marketing"
+ output = (known after apply)
}
# terraform_data.group["marketing"] will be created
+ resource "terraform_data" "group" {
+ id = (known after apply)
+ input = "grp-marketing-data"
+ output = (known after apply)
}
# terraform_data.reader["sales/marketing"] will be created
+ resource "terraform_data" "reader" {
+ id = (known after apply)
+ input = {
+ group = "grp-sales-data"
+ role = "Storage Blob Data Reader"
+ scope = "marketing"
}
+ output = (known after apply)
}
# terraform_data.writer["marketing"] will be created
+ resource "terraform_data" "writer" {
+ id = (known after apply)
+ input = {
+ group = "grp-marketing-data"
+ role = "Storage Blob Data Contributor"
+ scope = "marketing"
}
+ output = (known after apply)
}
Plan: 4 to add, 0 to change, 0 to destroy.
─────────────────────────────────────────────────────────────────────────────
Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.The plan creates exactly four things: container["marketing"], group["marketing"], writer["marketing"] and reader["sales/marketing"]. Plan: 4 to add, 0 to change, 0 to destroy.
My tips for this pattern:
- Never rename a key - without a moved block
- Validate the YAML - in a terraform test
- Data team owns the file - the platform team reviews the pull request
Key points to remember
- count = position: remove one item and later ones shift and get replaced
- for_each = stable keys for named things; moved blocks switch without recreating
- for_each keys must be known at plan: static keys, unknown values
- dynamic blocks for nested blocks; for expressions reshape lists into maps
- Conditional with count 0 or 1 + one(); one YAML file drives domain onboarding
The full script
This is a Python script that writes every file and runs every command above in order: put Terraform 1.17 on your PATH and run it in an empty folder.
Show the full script that writes the files and runs every step
# ========================================================================
# Terraform Interview Q&A #5 - count, for_each and dynamic blocks
# TechBrothersIT. Names and sample values are invented for teaching.
# Every step below was run with Terraform 1.17.0; the outputs in the video and the blog post are the real outputs.
# Providers used: hashicorp/random, null, local, time (they create nothing in any cloud). Azure and Databricks
# code in this series is checked with terraform validate only: nothing is created in a real subscription.
# To run it yourself (Linux, macOS or WSL; some steps use grep / cat):
# 1. Install Terraform 1.17 from developer.hashicorp.com/terraform/install and put it on your PATH.
# 2. python Terraform_Interview_QA_05_Code.py in an empty folder (terraform init downloads the providers).
# ========================================================================
import os, subprocess
def write(path, text):
if text is None:
if os.path.exists(path): os.remove(path)
return
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(text.strip("\n") + "\n")
def run(cmd, expect_error=False, allow_error=False, env=None):
print("\n$ " + cmd, flush=True)
rc = subprocess.run(cmd, shell=True, env=dict(os.environ, **(env or {}))).returncode
if rc != 0 and not (expect_error or allow_error):
raise SystemExit("stopped: this command was expected to succeed")
# Step 0: the starting files
write('versions.tf', """\
terraform {
required_version = ">= 1.17"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.9"
}
}
}
""")
write('main.tf', """\
variable "containers" {
type = list(string)
}
# stand-in for azurerm_storage_container: the name forces replacement
resource "local_file" "container" {
count = length(var.containers)
filename = "${path.module}/lake/${var.containers[count.index]}.keep"
content = var.containers[count.index]
}
""")
write('terraform.tfvars', """\
containers = ["raw", "bronze", "silver", "gold", "sandbox"]
""")
write('pipeline/versions.tf', """\
terraform {
required_version = ">= 1.17"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.9"
}
}
}
""")
write('pipeline/main.tf', """\
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
locals {
sfx = random_string.suffix.result
}
# the KEYS depend on a value known only after apply
resource "terraform_data" "container" {
for_each = toset(["raw-${local.sfx}", "gold-${local.sfx}"])
input = each.key
}
""")
write('network/versions.tf', """\
terraform {
required_version = ">= 1.17"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.11"
}
}
}
""")
write('network/providers.tf', """\
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
""")
write('network/variables.tf', """\
variable "subscription_id" {
type = string
}
variable "ingress_ranges" {
type = map(object({
cidr = string
priority = number
}))
default = {
office = { cidr = "203.0.113.0/24", priority = 100 }
partner = { cidr = "198.51.100.0/24", priority = 110 }
vpn = { cidr = "192.0.2.0/24", priority = 120 }
}
}
""")
write('network/main.tf', """\
resource "azurerm_linux_web_app" "api" {
name = "app-sales-data-api-dev"
location = "eastus2"
resource_group_name = "rg-sales-data-dev"
service_plan_id = azurerm_service_plan.api.id
site_config {
ip_restriction_default_action = "Deny"
dynamic "ip_restriction" {
for_each = var.ingress_ranges
content {
name = "allow-${ip_restriction.key}"
ip_address = ip_restriction.value.cidr
priority = ip_restriction.value.priority
action = "Allow"
}
}
}
}
""")
write('network/plan.tf', """\
resource "azurerm_service_plan" "api" {
name = "asp-sales-data-dev"
location = "eastus2"
resource_group_name = "rg-sales-data-dev"
os_type = "Linux"
sku_name = "B1"
}
""")
write('network/tests/api.tftest.hcl', """\
mock_provider "azurerm" {}
variables {
subscription_id = "00000000-0000-0000-0000-000000000000"
}
run "one_rule_per_range" {
command = plan
assert {
condition = length(
azurerm_linux_web_app.api.site_config[0].ip_restriction
) == 3
error_message = "Expected one IP restriction per range."
}
}
""")
write('settings/main.tf', """\
variable "domains" {
type = list(object({
name = string
owner = string
retention = number
}))
default = [
{ name = "sales", owner = "sales-eng", retention = 30 },
{ name = "finance", owner = "fin-eng", retention = 90 },
{ name = "crm", owner = "sales-eng", retention = 30 },
]
}
locals {
by_name = { for d in var.domains : d.name => d }
}
resource "terraform_data" "retention" {
for_each = local.by_name
input = "${each.key}: ${each.value.retention} days"
}
""")
write('alerts/main.tf', """\
variable "environment" {
type = string
default = "dev"
}
# only production gets an alert action group
resource "terraform_data" "alerts" {
count = var.environment == "prod" ? 1 : 0
input = "ag-sales-data-${var.environment}"
}
output "alert_group" {
value = terraform_data.alerts[0].input
}
""")
write('domains/domains.yaml', """\
domains:
sales:
owner: sales-data-team
readers: [finance]
finance:
owner: finance-data-team
readers: []
""")
write('domains/locals.tf', """\
locals {
domains = yamldecode(file("${path.module}/domains.yaml")).domains
# one entry per reader and domain, e.g. "finance/sales"
readers = merge([
for n, d in local.domains : {
for r in d.readers : "${r}/${n}" => { reader = r, domain = n }
}
]...)
}
""")
write('domains/main.tf', """\
# stand-ins for azurerm_storage_container, an Entra ID group
# and azurerm_role_assignment: the for_each keys are the same
resource "terraform_data" "container" {
for_each = local.domains
input = each.key
}
resource "terraform_data" "group" {
for_each = local.domains
input = "grp-${each.key}-data"
}
resource "terraform_data" "writer" {
for_each = local.domains
input = {
role = "Storage Blob Data Contributor"
group = terraform_data.group[each.key].input
scope = terraform_data.container[each.key].input
}
}
resource "terraform_data" "reader" {
for_each = local.readers
input = {
role = "Storage Blob Data Reader"
group = terraform_data.group[each.value.reader].input
scope = terraform_data.container[each.value.domain].input
}
}
""")
run('terraform fmt -check -recursive')
run('terraform init')
run('terraform apply -auto-approve')
run('terraform -chdir=pipeline init')
run('terraform -chdir=network init -backend=false')
run('terraform -chdir=settings init')
run('terraform -chdir=alerts init')
run('terraform -chdir=domains init')
run('terraform -chdir=domains apply -auto-approve')
# Step 1: Five containers with count
run('terraform state list')
# Step 2: Remove bronze: every index after it shifts
write('terraform.tfvars', """\
containers = ["raw", "silver", "gold", "sandbox"]
""")
run('terraform plan -refresh=false')
# Step 3: for_each needs a map or a set of strings
write('terraform.tfvars', """\
containers = ["raw", "bronze", "silver", "gold", "sandbox"]
""")
write('main.tf', """\
variable "containers" {
type = list(string)
}
# stand-in for azurerm_storage_container: the name forces replacement
resource "local_file" "container" {
for_each = var.containers
filename = "${path.module}/lake/${each.key}.keep"
content = each.key
}
""")
run('terraform plan', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 4: for_each over a set, with moved blocks
write('main.tf', """\
variable "containers" {
type = list(string)
}
# stand-in for azurerm_storage_container: the name forces replacement
resource "local_file" "container" {
for_each = toset(var.containers)
filename = "${path.module}/lake/${each.key}.keep"
content = each.key
}
""")
write('moved.tf', """\
moved {
from = local_file.container[0]
to = local_file.container["raw"]
}
moved {
from = local_file.container[1]
to = local_file.container["bronze"]
}
moved {
from = local_file.container[2]
to = local_file.container["silver"]
}
moved {
from = local_file.container[3]
to = local_file.container["gold"]
}
moved {
from = local_file.container[4]
to = local_file.container["sandbox"]
}
""")
run('terraform plan -refresh=false')
# Step 5: Apply the moves
run('terraform apply -auto-approve -refresh=false')
# Step 6: Remove bronze again: only bronze goes
write('terraform.tfvars', """\
containers = ["raw", "silver", "gold", "sandbox"]
""")
run('terraform plan -refresh=false')
# Step 7: Keys known only after apply
run('terraform -chdir=pipeline plan', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 8: Static keys, unknown values
write('pipeline/main.tf', """\
resource "random_string" "suffix" {
length = 6
special = false
upper = false
}
locals {
sfx = random_string.suffix.result
}
# static keys; only the VALUES are known after apply
resource "terraform_data" "container" {
for_each = toset(["raw", "gold"])
input = "${each.key}-${local.sfx}"
}
""")
run('terraform -chdir=pipeline plan')
# Step 9: A dynamic block, validated and tested with a mock provider
run('terraform -chdir=network validate')
run('terraform -chdir=network test')
# Step 10: for expressions in terraform console
run("echo '{ for d in var.domains : d.name => d.retention }' | terraform -chdir=settings console")
run("echo '[for d in var.domains : d.name if d.retention > 60]' | terraform -chdir=settings console")
# Step 11: Duplicate keys
run("echo '{ for d in var.domains : d.owner => d.name }' | terraform -chdir=settings console", expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 12: Group by key with the ellipsis
run("echo '{ for d in var.domains : d.owner => d.name... }' | terraform -chdir=settings console")
# Step 13: The map feeds for_each
run('terraform -chdir=settings plan')
# Step 14: Index [0] on a resource that has zero instances
run('terraform -chdir=alerts plan', expect_error=True) # EXPECTED TO FAIL: the video shows this error
# Step 15: one() handles zero or one instance
write('alerts/main.tf', """\
variable "environment" {
type = string
default = "dev"
}
# only production gets an alert action group
resource "terraform_data" "alerts" {
count = var.environment == "prod" ? 1 : 0
input = "ag-sales-data-${var.environment}"
}
output "alert_group" {
value = one(terraform_data.alerts[*].input)
}
""")
run('terraform -chdir=alerts plan')
run('terraform -chdir=alerts plan -var environment=prod')
# Step 16: The same switch with for_each
write('alerts/main.tf', """\
variable "environment" {
type = string
default = "dev"
}
# only production gets an alert action group
resource "terraform_data" "alerts" {
for_each = var.environment == "prod" ? toset(["prod"]) : toset([])
input = "ag-sales-data-${each.key}"
}
output "alert_group" {
value = one([for a in terraform_data.alerts : a.input])
}
""")
run('terraform -chdir=alerts plan -var environment=prod')
# Step 17: Onboard marketing: one YAML entry
write('domains/domains.yaml', """\
domains:
sales:
owner: sales-data-team
readers: [finance]
finance:
owner: finance-data-team
readers: []
marketing:
owner: marketing-data-team
readers: [sales]
""")
run('terraform -chdir=domains plan -refresh=false')
Part 6: Environments and Workspaces
The sample data on this page is invented for teaching. Every terminal output is the real output of the command above it, run with Terraform 1.17.0; IDs, timestamps and durations are masked. Azure and Databricks code is checked with terraform validate. Part of the Real Time Terraform Interview Questions and Answers series on TechBrothersIT. Features, limits and their status (GA or preview) are current as of October 2026; check the official Terraform documentation (developer.hashicorp.com/terraform) for anything you quote in an interview.



No comments:
Post a Comment
Note: Only a member of this blog may post a comment.