

Managing configuration across environments is where most Terraform setups start to buckle. The var.instance_type = "t3.micro" approach works fine for one environment. For three environments, you have three variables. For ten, you have ten nearly-identical variable declarations spread across multiple files, waiting to drift apart.
Terraform map variables fix this directly. A single map(string) holds all your environment-specific values in one place. A single lookup pulls the right value at apply time. No duplicated declarations, no environment-specific variable files for every new config key.
The second place maps cause problems is subtler: they work beautifully as a structure, but at scale they need governance. Who changed the prod entry last week? Did that plan get reviewed? Which environments are running the old instance type because someone forgot to update the map? That's the gap between "map variables as a pattern" and "map variables in production."
This guide covers the full range of map variable patterns: basic map(string), map(object) with optional attributes, for_each integration, and the map manipulation functions (lookup, merge, tomap, flatten) that make maps genuinely useful in production configurations. Every example uses current Terraform 1.15.x and AWS provider v5 syntax.
Last updated: May 2026.
At a glance A Terraform map variable is a key-value data structure where every value shares the same type constraint. Current versions: Terraform v1.15.1 (May 2026), verified via github.com/hashicorp/terraform/releases, and OpenTofu v1.11.6 (April 2026), verified via github.com/opentofu/opentofu/releases. All patterns in this guide work identically in both. The core use case: encoding per-environment or per-region configuration in one variable instead of many, then driving
for_eachorlookupfrom that single source of truth.
What is a Terraform map variable?
A map variable stores key-value pairs where every value must be the same type. The keys are always strings. The values can be strings, numbers, booleans, lists, objects, or even other maps.
variable "region_ami" {
description = "AMI ID to use in each AWS region."
type = map(string)
default = {
"us-east-1" = "ami-0123456789abcdef0"
"us-west-2" = "ami-0abcdef1234567890"
"eu-west-1" = "ami-0fedcba9876543210"
}
}
Access any value with bracket notation: var.region_ami["us-east-1"]. Dot notation (.us-east-1) does not work here. Bracket notation is required for map keys because keys can be any string expression, including variables and computed values, not just static identifiers. Dot notation works on objects, which are a different type.
Map vs. object. Both use curly braces and key-value pairs, which confuses people. The distinction is the type constraint. A map requires all values to be the same type. An object lets each attribute have its own type. In practice: use map(string) when you're configuring the same thing per environment or region; use object({}) when you're describing a resource with multiple different fields.
Maps work identically in OpenTofu. The OpenTofu vs. Terraform comparison covers the broader framework differences if you're evaluating which path to take. For map variables specifically, the syntax, functions, and for_each semantics are identical in both tools.
Related reading: Terraform variables: a complete guide covers input variables, output values, locals, and tfvars side by side. Useful context for where maps fit in the broader variable system.
Terraform map types and when to use each
The type constraint controls what values are valid inside the map. The complete set:
| Type | Use this when |
|---|---|
map(string) |
All values are strings: AMI IDs, region names, environment labels, tag values |
map(number) |
Numeric values per key: replica counts, port numbers, timeout durations |
map(bool) |
Feature flags per environment: { dev = true, prod = false } |
map(list(string)) |
Each key maps to a list: availability zones per region, allowed CIDR ranges per environment |
map(set(string)) |
Same as map(list(string)) but enforces uniqueness within each value |
map(object({...})) |
Complex config per key: full instance specs, network settings, any multi-field per-environment config |
map(map(string)) |
Two-level lookup tables: environment × region → AMI ID |
One syntax note: map(list) and map(set) are not valid HCL. The element type must be specified in full: map(list(string)), map(set(number)), and so on.
map(string)
The most common case. Store string values keyed by environment, region, or any identifier:
variable "instance_type_by_env" {
description = "EC2 instance type for each deployment environment."
type = map(string)
default = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.large"
}
}
Reference the right value at apply time: var.instance_type_by_env[var.environment]. No conditionals, no count-based workarounds. The pattern scales to any number of environments by adding one line to the map.
map(object)
When each key needs multiple fields with different types, use map(object({...})):
variable "instance_config" {
description = "Per-region instance configuration."
type = map(object({
instance_type = string
ami = string
ebs_optimized = bool
}))
default = {
"us-east-1" = {
instance_type = "t3.small"
ami = "ami-0123456789abcdef0"
ebs_optimized = true
}
"us-west-2" = {
instance_type = "t3.micro"
ami = "ami-0abcdef1234567890"
ebs_optimized = false
}
}
}
Access individual attributes with dot notation after the key lookup: var.instance_config["us-east-1"].ebs_optimized.
map(map(string))
Two-level lookups, useful when your configuration varies along two dimensions, such as environment and region:
variable "env_region_ami" {
description = "AMI ID indexed by environment and then region."
type = map(map(string))
default = {
prod = {
"us-east-1" = "ami-0123456789abcdef0"
"us-west-2" = "ami-0abcdef1234567890"
}
staging = {
"us-east-1" = "ami-0fedcba9876543210"
"us-west-2" = "ami-0987654321fedcba0"
}
}
}
Retrieve with two bracket lookups: var.env_region_ami["prod"]["us-east-1"]. No conditional logic, no nested if-else chains.
Using Terraform map variables with for_each
The most common use of map variables in production Terraform is as the input set for for_each. When for_each receives a map, it creates one resource instance per key, with each.key and each.value available in the resource body.
variable "vpc_config" {
description = "VPC settings per deployment environment."
type = map(object({
cidr_block = string
enable_dns_support = bool
}))
default = {
dev = {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
}
staging = {
cidr_block = "10.1.0.0/16"
enable_dns_support = true
}
prod = {
cidr_block = "10.2.0.0/16"
enable_dns_support = true
}
}
}
resource "aws_vpc" "environments" {
for_each = var.vpc_config
cidr_block = each.value.cidr_block
enable_dns_support = each.value.enable_dns_support
tags = {
Name = each.key
Environment = each.key
}
}
This creates three VPCs: aws_vpc.environments["dev"], aws_vpc.environments["staging"], and aws_vpc.environments["prod"]. Adding a new environment means adding one entry to the map. Terraform handles the resource creation.
Map keys become stable resource addresses. If you rename a key, Terraform destroys and recreates that resource. Choose keys that won't change: environment names, region codes, or service identifiers work well. Keys that might change (generated names, auto-incrementing IDs) will cause unnecessary churn.
One pattern we've seen go wrong repeatedly: a team uses auto-generated names as map keys during prototyping, then the naming convention changes. What looked like a variable update was actually a destroy-then-recreate of every resource in the map. With a for_each-driven map, Terraform is very literal: a different key is a different resource.
Before changing map keys in a live environment: always run
terraform planand review everydestroyedresource in the output. env zero's plan preview shows the full blast radius before you approve (see how it works).
Related reading: Terraform for_each: examples, tips, and best practices covers
for_eachwith sets and maps in depth, including how to handle key changes without destroying resources.
Advanced examples
S3 bucket with map variable
This example uses a map(string) to configure per-environment S3 storage classes.
Provider compatibility note: This example uses AWS provider v5 syntax. The old
lifecycle_rule {}block insideaws_s3_bucketand theaclattribute were both removed in provider v4 (April 2022). If you find either in existing Terraform code, replace them withaws_s3_bucket_lifecycle_configurationandaws_s3_bucket_aclas separate resources. Using the old syntax with a current provider version will fail with an unsupported argument error.
variable "storage_class_by_env" {
description = "S3 storage class to apply after 30 days, per environment."
type = map(string)
default = {
dev = "STANDARD_IA"
staging = "STANDARD_IA"
prod = "STANDARD"
}
}
variable "environment" {
type = string
default = "dev"
}
resource "aws_s3_bucket" "data" {
bucket = "platform-${var.environment}-data"
}
resource "aws_s3_bucket_lifecycle_configuration" "data" {
bucket = aws_s3_bucket.data.id
rule {
id = "transition-old-objects"
status = "Enabled"
filter {}
transition {
days = 30
storage_class = var.storage_class_by_env[var.environment]
}
}
}
Changing var.environment from dev to prod automatically selects STANDARD instead of STANDARD_IA. No conditional logic, no duplicated resource blocks.
Using the lookup() function with map(object)
lookup() returns a value from a map by key, falling back to a default if the key is absent. The signature is lookup(map, key, default).
For map(object), the default value must match the object schema:
variable "instance_configs" {
description = "Per-region instance configuration, with us-east-1 as the fallback."
type = map(object({
ami = string
instance_type = string
}))
default = {
"us-east-1" = {
ami = "ami-0123456789abcdef0"
instance_type = "t3.small"
}
"us-west-2" = {
ami = "ami-0abcdef1234567890"
instance_type = "t3.micro"
}
}
}
variable "region" {
type = string
default = "us-east-1"
}
locals {
# Fall back to us-east-1 config if the requested region isn't in the map.
region_config = lookup(
var.instance_configs,
var.region,
var.instance_configs["us-east-1"]
)
}
resource "aws_instance" "web" {
ami = local.region_config.ami
instance_type = local.region_config.instance_type
}
If the requested region is always guaranteed to be present, prefer the direct bracket accessor: var.instance_configs[var.region].ami. It fails loudly with a clear error on an unknown key, rather than silently returning a fallback you may not expect.
Related reading: Terraform lookup function: examples, use cases, and best practices covers the full
lookup()signature and edge cases including the deprecation note on omitting the default.
Converting a list to a map
When you have two parallel lists (one of keys, one of values), convert them to a map using a for expression with the => operator:
variable "environments" {
type = list(string)
default = ["dev", "staging", "prod"]
}
variable "cidr_blocks" {
type = list(string)
default = ["10.0.0.0/16", "10.1.0.0/16", "10.2.0.0/16"]
}
locals {
# Zip the two lists into a map: environment name => CIDR block.
vpc_map = {
for idx, env in var.environments :
env => var.cidr_blocks[idx]
}
}
resource "aws_vpc" "all" {
for_each = local.vpc_map
cidr_block = each.value
tags = {
Name = each.key
}
}
The lists must have the same length. If they can diverge in a module, a validation block catches the mismatch before Terraform attempts any indexing:
variable "environments" {
type = list(string)
default = ["dev", "staging", "prod"]
validation {
condition = length(var.environments) == length(var.cidr_blocks)
error_message = "environments and cidr_blocks must have the same number of entries."
}
}
This surfaces a clear error at plan time instead of an index-out-of-bounds panic mid-apply.
Learn more about for expressions in the Terraform for loops guide.
optional() attributes in map(object)
Since Terraform 1.3 (October 2022), you can mark individual attributes inside map(object(...)) as optional, with or without a default value. This makes module inputs more ergonomic: callers only need to provide the fields they care about, and missing fields fall back to declared defaults.
variable "service_config" {
description = "Per-service deployment configuration."
type = map(object({
instance_type = string # required
min_capacity = optional(number, 1) # defaults to 1 if omitted
max_capacity = optional(number, 3) # defaults to 3 if omitted
enable_logging = optional(bool, true) # defaults to true if omitted
}))
}
A caller can then omit the optional fields entirely:
service_config = {
api = {
instance_type = "t3.small"
max_capacity = 10 # override just one optional field
}
worker = {
instance_type = "t3.medium"
enable_logging = false # logging off for background workers
}
}
Terraform fills in min_capacity = 1 and enable_logging = true for the api service, and min_capacity = 1 and max_capacity = 3 for the worker service. The module stays clean; callers don't repeat boilerplate on every entry.
optional() also supports a two-argument form where the second argument is the default value. Without a default (optional(string)), the attribute defaults to null. Both forms are stable across Terraform 1.3 through 1.15.
Combining maps with merge()
merge() takes any number of maps and returns a single map, with later arguments overriding earlier ones when keys collide. The standard pattern is layering base defaults with environment-specific overrides:
variable "environment" {
type = string
default = "dev"
}
locals {
base_tags = {
managed_by = "terraform"
owner = "platform-team"
}
env_tags = {
environment = var.environment
cost_center = "engineering"
}
# env_tags wins on any shared keys.
all_tags = merge(local.base_tags, local.env_tags)
# Result: { managed_by = "terraform", owner = "platform-team",
# environment = "dev", cost_center = "engineering" }
}
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = local.all_tags
}
merge() works with any number of maps. It's particularly useful in modules where a base configuration map is defined once and each environment contributes its own additions. The last map in the argument list always wins on collisions. Order matters.
Map manipulation functions
Five built-in functions round out the map toolkit.
tomap() converts a value to a map type explicitly. Terraform handles most type coercion automatically, so tomap() is rarely needed in practice. The common case is constructing a derived map inside a for expression and passing it to an output or module input that expects a strict map(string) type:
locals {
services = [
{ name = "api", tier = "t3.small" },
{ name = "worker", tier = "t3.medium" },
{ name = "frontend", tier = "t3.micro" },
]
# Convert a list of objects into a name => tier map.
service_tiers = tomap({
for svc in local.services :
svc.name => svc.tier
})
}
One gotcha: when tomap() receives mixed-type values, it coerces everything to the most general type. A map containing a string and a bool will become map(string) with the bool converted to "true" or "false". If that's surprising behavior, define the type constraint explicitly.
flatten() turns a nested list into a flat list. To flatten a map into a list, project it first with a for expression:
locals {
ports_by_service = {
api = [80, 443]
metrics = [9090, 9091]
}
# Flatten all port lists into one list.
all_ports = flatten([
for svc, ports in local.ports_by_service : ports
])
# Result: [80, 443, 9090, 9091]
}
keys() returns a sorted list of all map keys. values() returns the corresponding list of values in the same order. Both are useful when you need to drive a count-based resource from a map, or when you need to iterate over just the keys or values without the full key-value pair:
variable "instance_type_by_env" {
type = map(string)
default = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.large"
}
}
locals {
env_names = keys(var.instance_type_by_env)
# Result: ["dev", "prod", "staging"] — always sorted alphabetically
instance_types = values(var.instance_type_by_env)
# Result: ["t3.micro", "t3.large", "t3.small"] — same order as keys()
}
One subtlety: keys() always returns results in lexicographic order, regardless of the order the map was defined. If ordering matters (it usually doesn't in for_each, but it does in count-based resources), that's the behavior to design around.
The removed map() function. If you encounter code like map("env", "prod", "region", "us-east-1"), that's the old variadic map() function from Terraform pre-0.12. It was removed when HCL2 introduced native map syntax. Replace it with a literal map: { env = "prod", region = "us-east-1" }.
Related reading: Terraform functions: a complete list with examples covers merge, flatten, tomap, and every built-in function with current examples.
Best practices for Terraform map variables
Keys are permanent. That's the most important thing to internalize. Once a map key drives a for_each, it's embedded in the Terraform state file as a resource address. dev, staging, and prod are better than env1, env2, env3, not just because they're readable, but because you can confidently promise they won't change. External IDs, auto-generated names, or anything that could rotate will eventually force a destroy-and-recreate cycle that you didn't intend.
Parallel maps are a maintenance trap. Separate map(string) variables for instance type and AMI seem cleaner at first: each variable does one thing. But they create silent coupling: both maps must always have identical keys, in the same order, and that invariant is maintained by convention, not by the type system. One map(object({instance_type, ami})) makes the relationship explicit and lets Terraform enforce it. The moment you see two maps that always get updated together, merge them.
Use optional() aggressively in module inputs. If you're writing a module that accepts map(object), mark any field with a sensible default as optional(type, default). The alternative is callers who either repeat boilerplate on every entry or who write conditional expressions inside the module body to handle missing keys. Both are worse. In practice, most modules we've seen have 3-4 required fields and 6-8 optional ones, and optional() is what keeps the calling code from being a wall of configuration.
Separate defaults from overrides with merge(). Define local.base_config for the things every environment shares, then merge() with environment-specific additions. Two benefits: the defaults are findable in one place, and code reviewers see only what actually changed. Without this pattern, reviewers end up diffing full map definitions to figure out what the actual change was.
lookup() is for recovery, not convenience. var.config[var.environment] fails loudly if the key is absent. lookup(var.config, var.environment, fallback) silently uses the fallback. The second form feels safer, but the silent failure is often worse than the loud one: a bad key returns a default, deploys successfully, and the misconfiguration isn't discovered until something behaves unexpectedly in production. Use bracket notation by default; reach for lookup() only when a missing key is a genuinely expected condition.
Compute maps in locals, not variables. If a map requires transformation (a for expression, a merge, a conditional), define it as a local. variable blocks should be pure input declarations: no logic, no derivation, no side effects. The distinction matters for testability: you can mock a variable, but you can't easily introspect a computed one. Keeping transformation in locals makes the data flow explicit.
For deeper coverage, see Terraform best practices: state management, reusability, and security and Terraform dynamic blocks. Dynamic blocks and map variables are frequently used together: the map provides the iteration set, the dynamic block generates nested configuration from each entry.
Common errors and how to fix them
"Values in map have inconsistent types"
This error appears when Terraform infers a map type from a literal and the values don't all match:
# This fails — Terraform infers map(string) from "t3.micro", then hits a bool
locals {
bad_map = {
instance_type = "t3.micro"
ebs_optimized = true
}
}
The fix is almost always to use map(object) instead of a bare map, or to declare the variable type explicitly so Terraform knows what to coerce to:
# Use map(object) when values have different types
variable "config" {
type = map(object({
instance_type = string
ebs_optimized = bool
}))
}
If you genuinely need a map(string) and the value is a bool, convert it with tostring(true).
"Invalid for_each argument: the given set contains unknown values"
This happens when a map value is computed at plan time but Terraform needs it at graph construction time. Common trigger: using a resource attribute (like an ID that doesn't exist yet) as a map key or as input to for_each.
# This fails if aws_vpc.main.id isn't known until apply
resource "aws_subnet" "by_env" {
for_each = { for env in var.environments : env => aws_vpc.main.id }
vpc_id = each.value
}
The fix is to break the dependency: use a known value as the key, and reference the computed value only in the resource body attributes where it's allowed:
resource "aws_subnet" "by_env" {
for_each = toset(var.environments)
vpc_id = aws_vpc.main.id # computed value in body — fine
cidr_block = var.subnet_cidrs[each.key]
}
map() function call failing
If you see an error on a line like map("key", "value", "key2", "value2"), that's the variadic map() function removed in Terraform 0.12. Replace it with native HCL map syntax:
# Old (pre-0.12, no longer valid)
tags = map("env", "prod", "region", "us-east-1")
# Current
tags = {
env = "prod"
region = "us-east-1"
}
Using Terraform maps with env zero
Writing map variables is straightforward. Managing them across dozens of environments is where most teams start losing track of what changed, when, and why.
The var.env_config pattern that looks clean in a single-team repository shows cracks at scale. Which value was active during the production incident last Tuesday? Who changed the prod entry in vpc_config three weeks ago, and from what CIDR to what? Did that change go through a plan review, or was it applied directly?
env zero addresses this at multiple levels.
Variable scoping. Variables in env zero are defined at the organization, project, or environment level, with lower levels inheriting and overriding higher ones. The pattern encoded in a map(object) can be expressed as a project-level variable: version-controlled, auditable for every change, and applied consistently across all environments in that project without copy-pasting. Variable sets let you group related variables into reusable bundles (the equivalent of your local.base_config merge pattern, enforced across teams).
Plan blast radius. When map variables change, for_each resources change, sometimes dozens of them at once. env zero shows the exact set of resources affected before the plan runs and enforces an approval workflow before apply. The full change history is auditable afterward: which value, which environment, who approved it, when it ran.
Drift detection. If a map variable gets updated in one environment but not another, the running infrastructure diverges from what Terraform state expects. env zero detects this continuously. Automation Anywhere went from full-day manual reviews to minutes per multi-region deployment once env zero made variable changes and their downstream effects visible. Drift, which had been invisible, became instantly detectable.
Code Optimizer. env zero's Code Optimizer scans your HCL statically for issues including parallel maps, missing optional() defaults, and other patterns covered in this guide, and generates pull requests with one-click fixes. It's not a linter you run manually; it runs on every plan.
For teams running both Terraform and OpenTofu, env zero handles both in the same platform. env zero is a founding member of OpenTofu's Technology Steering Committee. The Terraform docs and OpenTofu docs cover setup for both. Full variable management documentation is at docs.envzero.com/guides/admin-guide/variables.
Try it with env zero
Map variables solve the configuration structure problem. They don't solve who changed which value, in which environment, and what it affected.
env zero gives platform teams full visibility and control over variable management across every environment, without requiring engineers to export values manually or reconstruct audit trails from git blame.
Start a free trial or book a demo.
References
- Terraform: Types and Values: official map type documentation
- Terraform: Type Constraints and optional():
optional()formap(object), stable since Terraform 1.3 - Terraform: for Expressions: list-to-map conversion and map transformation syntax
- Terraform: variable block reference: full variable block syntax including
type,default, andvalidation - Terraform: lookup() function: current signature; note that omitting the default is deprecated since v0.7
- Terraform: merge() function: combining maps with last-argument-wins semantics
- Terraform: tomap() function: explicit type conversion and mixed-type coercion behavior
- Terraform: flatten() function: flattening nested lists and map projections
- AWS Provider v5 upgrade guide: S3 changes: covers the removal of the inline
lifecycle_ruleblock and theaclattribute; both now require separate resources - OpenTofu releases: current OpenTofu release history (v1.11.6 as of April 2026)
Frequently asked questions
What is the difference between a Terraform map and an object?
A map requires all values to be the same type. An object allows each attribute to have its own type. Use map(string) when your keys all map to the same type; use object({...}) when you need a fixed set of named fields with potentially different types. In resource blocks, most provider-managed attribute collections are objects. In variable declarations for per-environment config, maps are almost always the right choice.
Does map(object) work the same way in OpenTofu?
Yes. All map types, the optional() modifier, and the map manipulation functions in this post work identically in OpenTofu v1.11.6. The type system is shared; the HCL syntax is the same.
When should I use a map variable vs. for_each with a set?
Use a map when each resource instance needs more than one configuration value. The map key identifies the resource; the map value carries its configuration. Use a set when you need to create N identical resources keyed by a plain string (like a list of bucket names with no per-bucket configuration). For anything more complex than names, maps are almost always the right choice.
Can I use optional() inside map(object())?
Yes. optional() inside map(object({...})) is stable as of Terraform 1.3 (October 2022). Mark any attribute that has a sensible default as optional(type, default_value). Callers who omit the attribute get the default automatically; callers who set it explicitly override it. No conditional logic needed inside the module.
What happens if I access a map key that doesn't exist?
Terraform raises an error and halts. If you want a fallback instead of an error, use lookup(var.map, var.key, default_value). As a rule: use bracket notation when the key must exist (loud failure is correct), and lookup() when a missing key is a valid scenario you want to handle gracefully.
How does changing a map variable affect running infrastructure?
Adding a key triggers creation of new resources via for_each. Removing a key triggers destruction. Changing a value triggers an in-place update or replacement, depending on whether the changed attribute forces resource recreation. This is exactly the scenario where review and auditability matter most: a one-line change to a map variable can affect dozens of resources across multiple environments. Running terraform plan before applying shows the full impact, and env zero tracks that impact and the full variable history for every deployment.


.webp)

![Using Open Policy Agent (OPA) with Terraform: Tutorial and Examples [2026]](https://cdn.prod.website-files.com/63eb9bf7fa9e2724829607c1/69d6a3bde2ffe415812d9782_post_th.png)