← Back to Blog RSS

Terraform Map Variables: A Practical Guide

Terraform Infrastructure DevOps

Most Terraform sprawl is not caused by modules, providers, or even bad naming. It starts earlier, when teams refuse to model repeating data as data, and keep cloning the same configuration for dev, staging, prod, and every region after that.

TL;DR
$ cat terraform-map-variables.tldr
• A Terraform map lets you keep related key value pairs in a single variable, which is far cleaner than scattering environment-specific values across separate input variables.
• The most useful Terraform map examples combine map(string) or map(object({...})) with lookup(), for_each, and local values.
• Maps are strict about value types, and that strictness is exactly what makes Terraform code easier to validate during the terraform plan command.
• Once your infrastructure configurations become map-driven, state management matters more because one map can fan out changes across many resources.

Terraform Map Variables: How They Work and When to Use Them

In HCL, a map is a collection of values identified by string keys, which makes it a natural fit for environment names, regions, tags, CIDR blocks, and any other setting where you want one variable to store multiple values without losing structure.

This guide walks through how to define a map, how the main map type variants work, where real Terraform map examples show up in practice, which built-in Terraform functions matter most, and which habits keep the configuration clean as it grows.

What is a Terraform map variable?

A Terraform map is a collection type in HCL that stores key value pairs, where each key is a string label and each value must conform to the same element type. In practical terms, that means a map variable is ideal when you want one variable to hold environment names to instance types, region names to AMI IDs, or availability zones to subnet CIDR blocks, instead of creating a separate variable for every specific value.

That sounds abstract until you look at the shape. A simple map is just one variable, one predictable structure, and many keyed values.

instance_types
 
├── dev = "t3.micro"
├── staging = "t3.small"
└── prod = "t3.large"

That is the real appeal. You keep related configuration data together, you reduce duplication, and you make the relationship between environments and values obvious to the next person reading the file.

Terraform map types

Once the basic shape is clear, the next question is not whether to use a map, but what kind of values the map should hold. Terraform collection types always expect a single element type, while object lets you define a specific structure with named attributes, and in Terraform 1.3 and later, optional object attributes can include defaults.

Type What it stores When to use it
map(string) A map of text values Good for regions, instance sizes, names, or other simple configuration strings
map(number) A map of numeric values Useful for port numbers, replica counts, or timeout settings
map(bool) A map of boolean flags Useful for feature toggles, backup switches, or monitoring flags
map(list) A map where each value is a list Useful when one key needs an ordered sequence of related values
map(set) A map where each value is a set Useful when each key maps to unique elements and ordering does not matter
map(object({...})) A map of structured objects Best when each key needs multiple attributes, such as tags, CIDRs, owners, or policies

In day-to-day Terraform workflows, map(object({...})) is usually the tipping point between a simple map and a maintainable one. The moment each environment needs more than one attribute, a flat map stops being expressive enough.

How to declare a Terraform map variable

After choosing the right value type, the declaration itself is simple. A variable block usually includes a description, a type, and, often, a default value, and it's recommended to set a description and type for variables, with a default when practical.

variable "instance_types" {
  description = "Map of environment names to EC2 instance types"
  type = map(string)
  default = {
  dev = "t3.micro"
  staging = "t3.small"
  prod = "t3.large"
  }
}

The above code block is copy-paste ready in variables.tf, and it already tells Terraform a lot.

The input must be a map, each key is a string label, and each map value must resolve to the same type. That means terraform plan can catch bad input earlier, which is exactly why explicit variable types keep infrastructure code cleaner than loose defaults and guesswork.

How to use a Terraform map

The most direct way to access a Terraform map variable is bracket notation with square brackets. If your variable is var.instance_types, then var.instance_types["prod"] retrieves the value for the prod key, which makes direct indexing a good fit when you are confident the key exists.

Terraform also supports the Terraform lookup function, which returns a default value when the key is missing.

instance_type = var.instance_types["prod"]
 
instance_type = lookup(var.instance_types, "prod", "t3.micro")

That second form is often the better habit. The lookup function makes the fallback explicit, which helps when environment names are passed in from another variable or when a map may not yet include every case.

Once you're comfortable reading one map value, the next step is letting Terraform iterate across the whole map for you.

Iterating with for_each

This is where maps stop being just storage and start shaping resources. When a resource uses for_each with a map, Terraform creates one instance per key value pair, exposes each.key and each.value, and tracks each instance by its map key.

variable "instance_types" {
  type = map(string)
  default = {
  dev = "t3.micro"
  staging = "t3.small"
  prod = "t3.large"
  }
}
 
resource "aws_instance" "app" {
  for_each = var.instance_types
 
  ami = "ami-1234567890abcdef0"
  instance_type = each.value
 
  tags = {
  Name = "app-${each.key}"
  Environment = each.key
  }
}

This pattern shows up in a lot of real Terraform map examples, because it replaces three nearly identical resource blocks with one resource and one map. Once that pattern feels natural, it becomes much easier to see where maps belong in real infrastructure.

Terraform map examples

Once direct access and iteration feel normal, the most useful Terraform map examples are the ones that replace actual duplication. These are the patterns that turn a repetitive Terraform configuration into a reusable one.

Environment-specific instance sizing using map(string)

A classic use case is storing environment-specific values for EC2 sizing. Instead of creating separate input variables for dev, staging, and prod, you can keep the relationship in one map and select the right specific value based on an environment variable.

variable "instance_types" {
  description = "Instance types by environment"
  type = map(string)
  default = {
  dev = "t3.micro"
  staging = "t3.small"
  prod = "t3.large"
  }
}
 
variable "environment" {
  type = string
  default = "dev"
}
 
resource "aws_instance" "web" {
  ami = "ami-1234567890abcdef0"
  instance_type = lookup(var.instance_types, var.environment, "t3.micro")
}

This step is small, but it matters. One map lets you store multiple values behind a single variable, and the resource block stays unchanged as environments change.

Multi-region AMI IDs using map(string)

Region-specific AMI IDs are another obvious fit. You can map region names to image IDs and use the provider region to select the correct image at runtime, which is much cleaner than sprinkling conditional expressions everywhere.

variable "ami_ids" {
  description = "AMI IDs by AWS region"
  type = map(string)
  default = {
  eu-west-1 = "ami-0aaa1111bbbb2222c"
  us-east-1 = "ami-0ddd3333eeee4444f"
  us-west-2 = "ami-07777666655554444"
  }
}
 
variable "aws_region" {
  type = string
  default = "eu-west-1"
}
 
resource "aws_instance" "app" {
  ami = lookup(var.ami_ids, var.aws_region, var.ami_ids["eu-west-1"])
  instance_type = "t3.micro"
}

The gain here is not just less code. It is that one map makes the regional variation explicit, which is exactly what keeps multi-region Terraform code readable six months later.

Per environment tag sets using map(object({...}))

Once one environment needs more than one attribute, a flat map starts to feel cramped. A map object lets you keep related values together, which is why it works well for tagging standards, ownership metadata, and cost allocation fields.

Terraform supports object schemas with optional attributes and defaults, which makes this pattern much more ergonomic in modern versions.

variable "environment_tags" {
  description = "Structured tag sets by environment"
  type = map(object({
  owner = string
  cost_center = string
  tier = optional(string, "application")
  }))
  default = {
  dev = {
  owner = "platform"
  cost_center = "rnd"
  }
  prod = {
  owner = "platform"
  cost_center = "ops"
  tier = "critical"
  }
  }
}
 
variable "environment" {
  type = string
  default = "dev"
}
 
resource "aws_instance" "api" {
  ami = "ami-1234567890abcdef0"
  instance_type = "t3.small"
 
  tags = {
  Environment = var.environment
  Owner = var.environment_tags[var.environment].owner
  CostCenter = var.environment_tags[var.environment].cost_center
  Tier = var.environment_tags[var.environment].tier
  }
}

This is usually the point where teams realize a complex object is not overkill—it's often the only way to keep a growing set of related data inside one coherent structure.

Subnet CIDR blocks by availability zone

Networking data also maps cleanly to key-based access. If each availability zone gets its own CIDR block, a map avoids repeated subnet declarations with hardcoded values scattered through the file.

variable "subnet_cidrs" {
  description = "Subnet CIDR blocks by availability zone"
  type = map(string)
  default = {
  eu-west-1a = "10.0.1.0/24"
  eu-west-1b = "10.0.2.0/24"
  eu-west-1c = "10.0.3.0/24"
  }
}
 
resource "aws_subnet" "private" {
  for_each = var.subnet_cidrs
 
  vpc_id = aws_vpc.main.id
  availability_zone = each.key
  cidr_block = each.value
}

This is still a simple map string pattern, but it scales surprisingly well. And once your maps start carrying real production data, locals become the easiest way to derive new maps instead of hardcoding another layer.

Using map variables with locals

That progression from raw inputs to derived data is exactly where local values help. Terraform locals assign names to expressions so you can reuse transformed data inside a module, and they can reference variables, function outputs, resources, and other local values.

Here is a simple before and after using merge() to layer environment overrides onto a base map.

variable "tags" {
  type = map(string)
  default = {
  Project = "payments"
  Owner = "platform"
  }
}
 
variable "environment" {
  type = string
  default = "dev"
}
 
locals {
  environment_overrides = {
  dev = {
  Environment = "dev"
  Backup = "false"
  }
  prod = {
  Environment = "prod"
  Backup = "true"
  }
  }
 
  effective_tags = merge(
  var.tags,
  lookup(local.environment_overrides, var.environment, {})
  )
}
 
resource "aws_instance" "app" {
  ami = "ami-1234567890abcdef0"
  instance_type = "t3.small"
  tags = local.effective_tags
}

The win is that you keep the input surface small while still producing the exact map a resource needs. Combine maps or objects with merge() and later arguments will override earlier ones, which makes it perfect for base defaults plus environment-specific values.

Terraform map functions

Once you start transforming a Terraform map instead of merely reading it, a small set of functions does most of the work. Terraform includes built-in functions for inspecting keys, pulling safe defaults, combining structures, and converting other data types into maps.

Function What it does Example
keys() Returns a list of map keys keys(var.instance_types)
values() Returns a list of map values values(var.instance_types)
lookup() Returns the value for a key, or a fallback lookup(var.instance_types, "prod", "t3.micro")
merge() Combines maps or objects, with later values winning merge(local.base_tags, local.extra_tags)
zipmap() Creates a map from a list of keys and a list of values zipmap(["dev", "prod"], ["t3.micro", "t3.large"])
tomap() Converts an object or similar value into a map tomap({ dev = "t3.micro", prod = "t3.large" })

A few details are worth remembering:

Converting a list to a map

This is the zipmap() pattern, and it is the standard answer when you have a flat list of keys and a matching list of values. Terraform builds the map by pairing elements at the same index from each list.

locals {
  env_names = ["dev", "staging", "prod"]
  instance_sizes = ["t3.micro", "t3.small", "t3.large"]
 
  instance_map = zipmap(local.env_names, local.instance_sizes)
}

It's a handy bridge when data starts life as a Terraform list but really wants to be a map.

Terraform map best practices

  1. Always declare an explicit type map, even when the input feels obvious

    Terraform can infer some data types, but explicit constraints catch bad input earlier and make module intent legible to humans reading the file later.

  2. Prefer lookup() with a fallback over direct indexing when a key might be absent

    Native index syntax is terse, but the Terraform lookup function makes the default value visible in the code and avoids brittle failures when maps evolve.

  3. Use map(object({...})) when each entry has multiple values

    A flat map works for one attribute per key, but as soon as you need owner, size, tier, or a bool value alongside a string, a structured object is easier to validate and far easier to read.

  4. Avoid nested maps unless the structure truly reflects the domain

    Deeply layered maps look clever for a week, then every reference turns into a long chain of square brackets that obscures what the resource actually needs.

  5. Keep environment-specific maps out of variables.tf once they stop being tiny defaults

    Terraform supports variable definition files such as terraform.tfvars, *.auto.tfvars, and explicit -var-file inputs, and HCP Terraform also supports workspace-specific variables, so large per-environment maps do not need to live inside the module interface itself.

Those habits do not just make the file prettier. They keep map-driven modules predictable, which is what you need before scaling them across teams, workspaces, and automated terraform plan pipelines.

Conclusion

A Terraform map is one of the simplest ways to reduce duplication in infrastructure code, because it groups related values into one structure, makes different environments easier to model, and works naturally with for_each, lookup(), locals, and built-in functions. Used well, maps keep configuration DRY without making it opaque.

Managing map-driven, multi-environment infrastructure at scale is exactly where state management starts to matter most. Explore the Stategraph Docs to see how teams can use Stategraph to keep Terraform state visible and manageable, review changes with confidence, and reduce drift as infrastructure grows.

Terraform map FAQs

What is the difference between a Terraform map and an object?

A map stores values under string keys, and all of those values must share the same element type. An object also stores named attributes, but each attribute can have its own declared type and schema, which makes objects better for structured records with multiple fields.

Can a Terraform map hold mixed value types?

Not cleanly, and not by design. Terraform maps are meant to contain one element type, so mixed values must be converted to a compatible common type or Terraform will reject them, which is why tomap() can turn a string and a boolean into strings in the resulting map.

How do you pass a map variable as a module input?

You declare the variable in the child module, then pass a matching map expression into the parent module block. Terraform treats child module inputs as arguments, so a parent can pass var.tags, local.effective_tags, or a literal map directly into the module call.

Try Stategraph free

If your Terraform state has reached the point where routine operations carry a real time cost, the subgraph approach is worth seeing in practice. Stategraph is self-hosted, works with your existing Terraform configuration, and does not require changes to your provider setup.

Get started with the docs or follow along as we build.