Terraform map variables
What is a Terraform map variable?
A map variable in Terraform is a collection of key-value pairs where every key is a string and every value shares the same type as every other value in that map. There's no fixed list of keys you have to declare up front, the way there is with Terraform's object type, and there's no ordering guarantee either; a map is accessed by key, not by position.
A map type is declared like any other typed variable, just with map() wrapping whatever type the values need to be.
A default value follows ordinary HCL map syntax: curly braces with key=value pairs inside.
Writing a map literal in native map syntax is the common approach. jsonencode() is a reasonable alternative when the source of the data is already JSON rather than HCL.
What a Terraform map variable actually does
When Terraform parses a variable declared as map(string), the type constraint is enforced before the value gets used anywhere else in the configuration.
Pass in a value with a number, or a bool value, where a string is expected, and Terraform either converts it automatically (numbers and strings convert fairly freely) or fails validation immediately, well before any resource block gets evaluated.
Accessing a value inside a map happens one of two ways, both of which are fine:
- Square bracket syntax,
var.tags["Environment"], works regardless of what the key actually looks like, including keys with spaces or special characters. - Dot syntax,
var.tags.Environment, works too, but only when the key happens to be a valid identifier, no spaces, no characters outside what HCL allows in an attribute name.
Iterating over a map (with a for expression, or with for_each on a resource) walks every key-value pair, but the order you get them in isn't guaranteed to match the order they were written in the source file.
If you need genuine ordering for what you're building, a map is the wrong option; a list, or a list of objects, gives you the position guarantee a map doesn't.
This is also why for_each on a resource block requires a map (or a set of strings) rather than a list in most situations. Resource creation through for_each depends on stable, unique keys to track multiple resources independently in state across multiple plans.
A list only offers position, which shifts the moment an element gets removed from the middle. This shift would cause Terraform to think that every later resource needs replacing instead of just the one that's actually gone.
Dynamic blocks come at the same idea from the other direction, iterating over a map to generate repeated nested blocks inside a single resource, rather than repeating the resource itself.
Map variables also show up on both sides of a module boundary. As module inputs, they let a caller pass in an open-ended set of values, tags, regions, and environment names, without the module author needing to predict every key in advance. As output values, a module can expose an internally built map, the kind a for expression often constructs, back out to whatever's calling it.
Setting a value at runtime works through the same channels as any other variable. A .tfvars file is the most common in real projects.
Marking a map variable sensitive = true works the same way as it does for any other variable type. Terraform redacts every value inside the map from the plan and apply output, replacing them with a generic placeholder rather than printing the actual contents to a terminal or a CI log.
Often, a map keyed by environment name holds environment-specific configuration, with separate settings for separate resources without a separate variable declaration for each one.
Picking which map applies often comes down to conditional expressions rather than a second variable – var.environment == "production" ? var.prod_settings : var.dev_settings – which keeps the conditional logic in one place instead of scattering environment checks across every resource that needs to react to it.
The values don't have to be plain strings either. Wrapping a more complex type inside the map (an object, for instance) gives you a map where each key points to a small structured record rather than a single value, as shown in the examples below.
Accessing a nested value just chains the syntax: var.storage_accounts["primary"].tier reaches into the map first by key, then into the object that key points to by attribute name.
An example of a Terraform map variable in action
Here is an example of a flat map of strings used for resource tagging.
Every key in var.tags becomes a tag key on the instance, and every value becomes that tag's value. Add a key to the map, and the next terraform plan shows a new tag appearing. Nothing else in the configuration has to change.
Naming a handful of resources is another common use case for a flat map(string), especially when a small, known list of bucket names needs to become buckets without writing out a separate resource block for each one by hand.
In this case, var.bucket_names defines three buckets: logs, backups, and uploads. for_each creates one aws_s3_bucket resource per entry, with each.key available as the resource's internal reference name and each.value supplying the actual bucket name for AWS to use.
You can add a fourth key to the map to create a fourth bucket on the next apply, but you don't need to change anything else about the resource block.
A map object doesn't have to hold simple values; each key below points to a small object describing one storage account, rather than a single string.
var.storage_accounts["primary"].tier resolves to "Standard". The keys (primary, backup) work exactly like the keys in the tags example, but each value is now a structured record with its own internal fields, accessed through each.value.tier and each.value.location once the map feeds into for_each.
Not every attribute inside that object necessarily needs a value supplied for every entry. Optional attributes (wrapping a field in optional()) allow some map entries to skip it entirely, falling back to a default instead of forcing every single entry to repeat the same value.
var.storage_accounts["primary"].tier now resolves to "Standard" even though the default for primary didn't mention tier, while backup overrides it explicitly with "Premium".
Without optional(), omitting tier on primary would fail validation, since every entry in a typed map of objects has to satisfy the full object shape unless told otherwise. Those object attributes aren't limited to scalars either. A field like allowed_cidrs could just as easily hold a nested list (a flat list of strings sitting inside the larger object) rather than another single value.
Combining a default map with resource-specific overrides rather than writing every tag out by hand on every resource is another common instance where map will appear.
merge() combines the two maps into one. When keys collide, later arguments override earlier ones, so ManagedBy and Team come from var.default_tags untouched, while Name gets added fresh, all without repeating the default tags on every single resource that needs them.
Common mistakes users make with a Terraform map variable
Here are a few common missteps.
Assuming a map preserves the order in which it was written
A for_each loop that seems to process things "out of order," or an output that looks shuffled compared to the source file, isn't actually wrong (maps simply never promised an order to begin with).
Accessing a key that doesn't actually exist in the map
var.tags["Region"] fails outright if Region was never set, rather than quietly returning an empty value the way some languages handle a missing dictionary key.
The lookup() function exists specifically to handle this gracefully, returning a fallback value instead of erroring when a key might or might not be present.
This code returns the value at "Region" if it exists, or the string "unspecified" if it doesn't, without erroring either way.
Getting the argument order backwards
A third, smaller trap shows up with merge() once more than one default map is in play. The argument order is important, and it's easy to write the call backwards without noticing right away.
merge(var.default_tags, override_tags) and merge(override_tags, var.default_tags) produce different results whenever a key exists in both, since whichever map appears last wins any collision. A Terraform plan that suddenly shows the wrong tag value after a merge() call gets reordered is usually this, not a deeper bug.
The nearest alternatives to a Terraform map variable
People coming from looser, dynamically typed languages sometimes expect to mix value types inside one collection, which a map doesn't allow.
There's an easy way to tell a map apart from a list. Order and position are important in a list; the first element is always the first element. In a map, the key is what matters: write the keys in a different order in the source file and it's still exactly the same map, because a key identifies its value by name, not position.
A map requires every key inside it to be unique. Two keys colliding inside the same map isn't allowed in the way two values in a list might happen to match, since a map's keys are the unique elements that make every entry findable in the first place.
Both an object and a map can hold named values, and the syntax for declaring each can look similar at a glance.
The difference is that an object has a fixed, known set of attributes, each potentially with its own type: you declare name and age and active up front, which is the list Terraform expects by default.
A map has no such ceiling. You can add a hundredth key without touching the type declaration at all, as long as that hundredth value matches the type every other value already uses.
Use an object when you know exactly what fields exist (and they don't all share one type). Use a map, the key-value data structure built for open-ended sets, when the set of keys is open-ended or might grow and every value plays the same role.
Terraform's variable types split into two broad categories:
- Primitive types – string, number, and bool – hold a single value each: a string value, a numeric value, a boolean value, with no internal structure.
- Complex types hold more than one value and are split further into two families: collection types and structural types.
Collection types – list, map, and set – hold any number of elements sharing one data type, exactly the rule a map enforces.
Structural types, object and tuple, hold a specific structure instead: a fixed set of named attributes, each potentially its own type.
A map sits in the collection family alongside list and set, while object belongs to the structural family – the cleanest way to see why the two get compared so often as data structures and why they're not interchangeable.
Declaring a variable as map(any) doesn't lift the shared-type rule; it tells Terraform to infer the single value type rather than having you name it. All values must still convert to one common type; a genuinely mixed set of values needs an object, or the standalone any type, instead.
It trades away the type safety a fully typed map gives you: Terraform can no longer catch a misplaced value at validation time, in exchange for accepting genuinely mixed data without restructuring it into something more rigid first.
Real configurations use map(any) sparingly, usually only when the values are coming from somewhere outside Terraform's control and forcing a single type onto them isn't realistic.
Stategraph's perspective on Terraform map variables
A map variable rarely sits still for long. Tags get added, storage tiers get bumped, an entry gets removed because a team got decommissioned, and every one of those changes is a value inside a variable that something downstream depends on.
"Do actual deployed resources still match what the map says they should?" is a drift detection question the moment anything changes outside a normal terraform apply, whether that gets caught by Terraform's own refresh behavior or by a tool built to automate the check.
Stategraph's blast radius analysis traces that same dependency chain at the resource level, so when a value inside a map like this changes, checking blast radius on the resources that read it surfaces everything downstream before you apply, not just the resource whose code was directly edited.
Related Terraform blogs
- Terraform's dependency graph is what actually turns a map into resources in the first place: with
for_each, each entry in the map becomes its own graph node, which is why the ordering guarantees discussed above matter for how that graph gets built. - Terraform workspaces are the built-in alternative to an environment-keyed map: instead of one variable holding settings for every environment, a workspace switches which state file a single configuration reads and writes, at the cost of having to check which one is active before every apply.
- Configuration drift is the failure mode Stategraph can help prevent: a map value and the resources it drives quietly falling out of sync with what's actually deployed. Blast radius is how far that kind of change spreads once it happens.