How to Reference for_each Resources in Terraform: Keys, Indexes and Import

When you add for_each to a resource, the resource stops being one object. It becomes a map of instances, one for each key. Every reference to it changes, terraform import needs a key in the address, and there is no count.index to tell you the position. This post answers the three questions that come from that change: how to reference the instances, how to get an index, and how to import existing objects into them.
TLDR
| Question | Short answer |
|---|---|
| How do I reference one instance? | aws_instance.servers["web"].private_ip |
| How do I reference all instances? | { for k, s in aws_instance.servers : k => s.private_ip } or values(aws_instance.servers)[*].id |
Is there a count.index for for_each? |
No. Convert the list to a map with { for i, v in list : i => v } and use each.key |
How do I import into a for_each resource? |
An import block with to = aws_instance.servers["web"], or for_each on the import block |
| Why "Index value required" on import? | The shell removed the quotes around the key. Quote the whole address |
Prerequisites
- Terraform 1.5 or later for
importblocks, and 1.7 or later forfor_eachinside animportblock. - The error messages and plan output in this post come from Terraform 1.15.8 with the built-in
terraform_dataresource, so you can repeat them without a cloud account. The AWS examples need the AWS provider and credentials.
How Do I Reference a Resource Created With for_each?
Put the key in square brackets after the resource name, then the attribute: <type>.<name>[<key>].<attribute>. The key is the map key, or the string itself when for_each is a set.
variable "instances" {
type = map(object({
instance_type = string
ami = string
}))
default = {
web = { instance_type = "t3.medium", ami = "ami-12345678" }
api = { instance_type = "t3.large", ami = "ami-87654321" }
}
}
resource "aws_instance" "servers" {
for_each = var.instances
ami = each.value.ami
instance_type = each.value.instance_type
tags = {
Name = each.key
}
}
output "api_private_ip" {
value = aws_instance.servers["api"].private_ip
}
aws_instance.servers is now a map with the keys "web" and "api". A reference without a key fails at validate time:
Error: Missing resource instance key
on main.tf line 6, in output "x":
6: value = terraform_data.servers.id
Because terraform_data.servers has "for_each" set, its attributes must be
accessed on specific instances.
For example, to correlate with indices of a referring resource, use:
terraform_data.servers[each.key]
A key that is not in the map fails at plan time with The given key does not identify an element in this collection value. Keys are case sensitive, so "Web" and "web" are different instances.
Reference All Instances
Use a for expression when you need an attribute from every instance. A map keeps the key next to each value, which makes outputs easier to read:
locals {
# list of IDs, in key order
instance_ids = [for k, s in aws_instance.servers : s.id]
# map of name => private IP
private_ips = { for k, s in aws_instance.servers : k => s.private_ip }
}
values(aws_instance.servers) turns the map into a list of objects, and a splat then picks one attribute: values(aws_instance.servers)[*].id. A splat directly on the map (aws_instance.servers[*].id) does not work, because splat expressions only work on lists, sets and tuples.
Create One Resource per Instance of Another
Pass the first resource to for_each of the second one. The keys stay the same, and each.value is the full instance object:
resource "aws_eip" "servers" {
for_each = aws_instance.servers
instance = each.value.id
domain = "vpc"
tags = {
Name = "${each.key}-eip"
}
}
This is also the answer when Terraform says a single attribute such as instance needs one ID and you passed the whole map. Either pick one key (aws_instance.servers["web"].id) or repeat the loop.
Use Only Some Instances
Filter on the input data, not on the created resources. Input values are known at plan time, so you can use them in for_each and count:
variable "instances" {
type = map(object({
instance_type = string
ami = string
tier = string
}))
}
locals {
web_keys = toset([for k, v in var.instances : k if v.tier == "web"])
}
resource "aws_lb_target_group_attachment" "web" {
for_each = local.web_keys
target_group_arn = aws_lb_target_group.web.arn
target_id = aws_instance.servers[each.key].id
port = 80
}
If you build the keys from attributes that only exist after apply, such as instance IDs, the plan fails with Invalid for_each argument. The variables, loops and outputs guide explains that error.
When a Key May Not Exist
Use try() to get null instead of an error:
output "db_ip" {
value = try(aws_instance.servers["database"].private_ip, null)
}
To create something only when a key exists, test the input variable: count = contains(keys(var.instances), "prod") ? 1 : 0.
Sets Have No Index
Some attributes are sets, for example vpc_security_group_ids on aws_instance. You cannot index a set with [0]:
Error: Invalid index
on main.tf line 10, in output "sg":
10: value = terraform_data.servers["web"].output.sgs[0]
├────────────────
│ terraform_data.servers["web"].output.sgs is a set of string
Elements of a set are identified only by their value and don't have any
separate index or key to select with, so it's only possible to perform
operations across all elements of the set.
Reference the security group resource directly, or convert with tolist() if any element will do. tolist() sorts strings, so "the first element" is the first in sort order, not the one you added first.
How Do I Get the List Index When Using for_each?
for_each has no index. It gives you each.key and each.value. To get a position, convert the list to a map whose keys are the positions:
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
locals {
az_by_index = { for i, az in var.availability_zones : i => az }
# { "0" = "us-east-1a", "1" = "us-east-1b", "2" = "us-east-1c" }
}
resource "aws_subnet" "private" {
for_each = local.az_by_index
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, tonumber(each.key))
availability_zone = each.value
}
Map keys are always strings, so each.key is "0", not 0. Terraform converts it automatically where a number is required, but tonumber(each.key) makes the intent clear.
Why for_each Has No Index
for_each exists so that an instance does not depend on its position. Here is what happens with count when you remove the middle item of ["web", "api", "worker"], run on Terraform 1.15.8:
# terraform_data.srv[1] will be updated in-place
~ input = "api" -> "worker"
# terraform_data.srv[2] will be destroyed
# (because index [2] is out of range for count)
- input = "worker" -> null
Plan: 0 to add, 1 to change, 1 to destroy.
Terraform does not delete api. It changes instance [1] into worker and destroys instance [2]. For a real server, changing an attribute such as ami forces a replacement, so you lose a server that you wanted to keep. With for_each = toset(["web", "api", "worker"]), the same change destroys only aws_instance.app["api"].
So a key made from a position, like the az_by_index map above, has the same problem as count. Prefer a key that means something:
locals {
# key by the AZ name, keep the position as a value
subnets = {
for i, az in var.availability_zones : az => {
index = i
cidr = cidrsubnet(var.vpc_cidr, 8, i)
}
}
}
resource "aws_subnet" "private" {
for_each = local.subnets
vpc_id = aws_vpc.main.id
availability_zone = each.key
cidr_block = each.value.cidr
tags = {
Name = "private-${each.value.index + 1}"
}
}
Now removing an AZ from the middle of the list removes one subnet, and the others keep their keys. Their CIDRs are still calculated from the position, though, so pin the CIDRs in the input if the list can change.
Combine Two Lists
zipmap() builds a map from a list of keys and a list of values:
locals {
instance_types = zipmap(
["web-1", "web-2", "api-1"],
["t3.medium", "t3.medium", "t3.large"],
)
# { "api-1" = "t3.large", "web-1" = "t3.medium", "web-2" = "t3.medium" }
}
The two lists must have the same length, and a duplicate key keeps the last value. A list of objects, keyed with { for app in var.apps : app.name => app }, is easier to keep correct than two lists that must stay in step.
When count Is the Better Choice
Use count for N copies of the same thing that you add and remove only at the end, and for on/off switches (count = var.enabled ? 1 : 0). Use for_each for a set of different things that each need a stable address. The resources and meta-arguments chapter of our Terraform guide compares them on a local project.
How Do I Import Into a for_each Resource?
Put the key in the import address. With an import block (Terraform 1.5 and later) you write the address as HCL, so there are no shell quotes to get wrong:
import {
to = aws_instance.servers["web"]
id = "i-1234567890abcdef0"
}
From Terraform 1.7, an import block takes for_each, so one block imports every instance:
locals {
existing = {
web = "i-1234567890abcdef0"
api = "i-0fedcba9876543210"
}
}
import {
for_each = local.existing
to = aws_instance.servers[each.key]
id = each.value
}
Run terraform plan to see the imports, then terraform apply. On a test with terraform_data, the plan shows each instance:
# terraform_data.servers["web"] will be imported
resource "terraform_data" "servers" {
id = "id-web"
}
Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.
The import blocks can stay in the code after the apply, or you can remove them. Terraform 1.16 and later also accept import blocks inside child modules.
Fix "Index value required" With terraform import
The terraform import command still works, but the shell processes the address first. If the shell removes the double quotes, Terraform gets aws_instance.servers[web] and stops:
Error: Index value required
on <import-address> line 1:
1: terraform_data.servers[web]
Index brackets must contain either a literal number or a literal string.
Quote the address so the double quotes reach Terraform. The Terraform documentation gives these forms:
# Linux, macOS, WSL (bash, zsh)
terraform import 'aws_instance.servers["web"]' i-1234567890abcdef0
# PowerShell
terraform import 'aws_instance.servers[\"web\"]' i-1234567890abcdef0
# Windows cmd.exe
terraform import aws_instance.servers[\"web\"] i-1234567890abcdef0
In a bash loop, put the variable inside escaped double quotes: terraform import "aws_instance.servers[\"$key\"]" "$id". Numeric string keys need quotes too: aws_subnet.private["0"].
Two other errors look similar:
Configuration for import target does not existmeans the address has no key, for exampleaws_instance.servers, or the key is not in thefor_eachmap.Invalid address ... Resource specification must include a resource type and namemeans you gave only the type.
Generate Configuration for for_each Imports
terraform plan -generate-config-out=generated.tf writes resource blocks for import targets that have no configuration. It does not work with instance keys:
Error: Configuration for import target does not exist
The given import block is not compatible with config generation. The
-generate-config-out option cannot be used with import blocks which use
for_each, or resources which use for_each or count.
Generate the configuration for one plain address such as aws_instance.imported, copy the arguments into your for_each resource, and then import with keys.
Change count to for_each Without Recreating
Use moved blocks instead of terraform state mv. They are part of the code, so the change goes through review and runs in CI like any other change:
resource "aws_instance" "servers" {
for_each = toset(["web", "api", "worker"])
# ...
}
moved {
from = aws_instance.servers[0]
to = aws_instance.servers["web"]
}
moved {
from = aws_instance.servers[1]
to = aws_instance.servers["api"]
}
moved {
from = aws_instance.servers[2]
to = aws_instance.servers["worker"]
}
The plan shows (moved from aws_instance.servers[0]) next to each instance. Check that it has no destroy before you apply. The state management chapter of the guide covers moved and removed blocks in more detail.
After any import, run terraform plan. A plan with changes means your configuration does not match the real object yet. Change the code until the plan is empty.
Related Resources
- Terraform Variables, Loops, and Outputs for
forexpressions, splats and outputs of counted resources. - Import existing objects in the Terraform guide.
- Terraform terminal simulator to practice
plan,applyandstatecommands in the browser. - Terraform Fundamentals Quiz and Terraform Basics flashcards to check what you know.
Try it hands-on
Run the commands from this article in the browser. Nothing to install.
Terraform Basics Simulator
Learn Terraform basics in an interactive browser lab. Read and edit the HCL config, run init, validate, plan, apply, and destroy, and watch state react to your edits. A free, hands-on way to learn infrastructure as code with no AWS account.
Terraform State Puzzle
Fix broken Terraform state without destroying production. Eight puzzles with real Terraform output: a rename that would replace a database, an import that forces replacement, a stale lock, drift, count index shifts and more. Choose between moved, import and removed blocks and the state commands.
We earn commissions when you shop through the links below.
Bitrise
Managed runners for GitHub Actions
Bitrise Build Hub runs your GitHub Actions jobs on M4 Macs and AMD Linux machines. Change runs-on and keep your workflows, secrets and logs on GitHub.
Atomsized
AWS platform engineering and GitOps
Design and automation for reliable AWS and Kubernetes platforms, safer delivery workflows, and preview and UAT environments your engineers can understand and own.
DigitalOcean
Cloud infrastructure for developers
Simple, reliable cloud computing designed for developers
DevDojo
Developer community & tools
Join a community of developers sharing knowledge and tools
SMTPfast
Developer-first email API
Send transactional and marketing email through a clean REST API. Detailed logs, webhooks, and embeddable signup forms in one dashboard.
QuizAPI
Developer-first quiz platform
Build, generate, and embed quizzes with a powerful REST API. AI-powered question generation and live multiplayer.
Want to support DevOps Daily and reach thousands of developers?
Become a SponsorTags
Found an issue?
Related Posts
Also worth your time on this topic
OpenTofu 1.13 Re-Encodes base64gzip and Drops WinRM. We Upgraded the Same State to See What Breaks
OpenTofu 1.13 shipped on September 30. We applied configs with 1.12.7 and planned them with 1.13.1 against the same state. Gzipped user data planned an update and a replacement with no config change. A WinRM provisioner passed validate and plan, then failed at apply. The new assume functions turned an Invalid count argument error into a clean plan. Here is what to check before you bump the version.
Terraform State Management
What is Terraform state, why is it important, and how do you manage state in a team environment?
mid
Terraform Repository Structure Checklist
Best practices for organizing and structuring your Terraform projects for maintainability and scalability.
30-45 minutes