Mastering Maps In Terraform

In today’s blog post, we will learn maps in terraform. In particular, how to define a map, how access a map elements, getting specific map elements, merging two maps data, different terraform functions related to maps, and best practices. So without any further delay, let us get started!

What are Maps?

In Terraform, a map is a collection type that stores a set of unique string keys, each associated with a value of any type. It is used to group related values and access them by a descriptive name. Maps/objects are represented by a pair of curly braces containing a series of <KEY> = <VALUE> pairs. The Key/value pairs can be separated by either a comma or a line break. The keys in a map must be strings; they can be left unquoted if they are a valid identifier, but must be quoted otherwise. 

Defining Maps and Objects

Maps and objects in Terraform both represent collections of named attributes. The distinction often lies in the rigidity of their type constraints:

  • Map (or map type): A collection where all values are expected to be of the same type. It is an unordered collection of key-value pairs, where keys are strings.
  • Object (or object type): A fixed set of named attributes, where each attribute can have a different type. Objects are used when you need a well-defined structure for heterogeneous data.

For simplicity, the term “map” is often used broadly to refer to key-value collections, but understanding the object type is also important.

Defining a Map

You define a map by enclosing its key-value pairs in curly braces {}, where keys are strings (often unquoted if they are simple identifiers) and values can be any Terraform type.

# main.tf

# Define a simple map of string to string (common for tags)
variable "common_tags" {
  description = "Common tags for resources."
  type        = map(string)
  default = {
    Environment = "dev"
    Project     = "Infra"
  }
}

# Define a map with mixed value types (implicitly becomes map(any))
output "resource_settings" {
  value = {
    instance_type = "t2.micro"
    min_capacity  = 1
    enabled       = true
  }
  # Outcome: { "instance_type" = "t2.micro", "min_capacity" = 1, "enabled" = true }
}

Explanation:

  • The common_tags variable is explicitly typed as map(string), ensuring all values are strings. This is a common pattern for defining resource tags.
  • The resource_settings output demonstrates a map where values have different types. Terraform infers the type as map(any) if not explicitly constrained, allowing flexibility.

Defining an Object

The object type is used when you need a specific, fixed structure where each attribute has a predefined type. This provides strong type checking and clarity for complex data structures.

# main.tf

# Define a variable with an object type constraint
variable "service_endpoint" {
  description = "Configuration for a service endpoint."
  type = object({
    url      = string
    port     = number
    is_secure = bool
  })
  default = {
    url       = "api.example.com"
    port      = 443
    is_secure = true
  }
}

output "endpoint_url" {
  value = var.service_endpoint.url
  # Outcome: "api.example.com"
}

Explanation:

  • The service_endpoint variable is defined as an object with specific attribute names (url, port, is_secure) and their corresponding types. This ensures data passed to this variable strictly adheres to the defined schema.
  • Object attributes are accessed using dot notation.

Accessing Map and Object Elements

You can retrieve values from maps and objects using dot notation or bracket notation. For safer access, especially when a key might be missing, the lookup and try functions are recommended.

Dot and Bracket Notation

# main.tf

variable "instance_specs" {
  type = map(string)
  default = {
    cpu    = "2 cores"
    memory = "4GB"
    disk   = "100GB SSD"
  }
}

# Accessing map elements using dot notation (for simple keys)
output "instance_cpu_dot" {
  description = "CPU specification using dot notation."
  value       = var.instance_specs.cpu
  # Outcome: "2 cores"
}

# Accessing map elements using bracket notation (for any key, including dynamic or with special characters)
output "instance_memory_bracket" {
  description = "Memory specification using bracket notation."
  value       = var.instance_specs["memory"]
  # Outcome: "4GB"
}

# Example with a key that might contain special characters or be dynamic
output "tag_with_hyphen" {
  value = {
    "my-tag-key" = "my-value"
  }["my-tag-key"]
  # Outcome: "my-value"
}

Explanation:

  • Dot notation (.key) is clean and preferred for simple, static keys.
  • Bracket notation (["key"]) is necessary for keys that are not valid identifiers (e.g., contain hyphens, start with numbers) or when the key itself is a variable or expression.

Safe Access with lookup

The lookup function allows you to retrieve a value from a map by key, providing a default value to return if the key does not exist. This prevents runtime errors.

# main.tf

output "lookup_disk_spec" {
  description = "Disk specification, with a default if not found."
  value       = lookup(var.instance_specs, "disk", "200GB HDD")
  # Outcome: "100GB SSD" (since 'disk' exists)
}

output "lookup_network_spec" {
  description = "Network specification, which doesn't exist so default is used."
  value       = lookup(var.instance_specs, "network", "Gigabit Ethernet")
  # Outcome: "Gigabit Ethernet" (since 'network' does not exist)
}

Explanation:

  • lookup is important for creating configurations that can gracefully handle missing optional attributes, falling back to sensible defaults.

Error Handling with try

The try function evaluates a sequence of arguments and returns the result of the first expression that succeeds without producing an error. This is useful for deeply nested or optional attributes.

# main.tf

variable "app_config" {
  type = object({
    database = optional(object({
      host = string
      port = number
    }))
    cache    = optional(object({
      enabled = bool
    }))
  })
  default = {
    database = { host = "db.example.com", port = 5432 }
    # cache attribute is missing in default
  }
}

output "database_host_try" {
  description = "Database host using try for safe access."
  value       = try(var.app_config.database.host, "default-db-host")
  # Outcome: "db.example.com"
}

output "cache_enabled_try" {
  description = "Cache enabled status using try for safe access."
  value       = try(var.app_config.cache.enabled, false)
  # Outcome: false (since 'cache' is optional and not present in default)
}

Explanation of the code:

  • try(var.app_config.database.host, "default-db-host") – if var.app_config.database.host is not defined the try function will return default-db-host
  • try(var.app_config.cache.enabled, false) – if var.app_config.cache.enabled is not defined or set, the try function will return false

Common Map Functions

Terraform offers specific functions for common map operations.

Getting Keys and Values

The keys function returns a list of all keys in a map, and the values function returns a list of all values.

# main.tf

output "instance_spec_keys" {
  description = "List of all keys in instance_specs."
  value       = keys(var.instance_specs)
  # Outcome: ["cpu", "memory", "disk"] (order not guaranteed)
}

output "instance_spec_values" {
  description = "List of all values in instance_specs."
  value       = values(var.instance_specs)
  # Outcome: ["2 cores", "4GB", "100GB SSD"] (order corresponds to keys, but overall map order not guaranteed)
}

Explanation:

  • keys and values are useful for iterating over map entries, especially when combined with for expressions or for logging/debugging purposes.

Merging Maps

The merge function combines two or more maps. If a key exists in multiple maps, the value from the rightmost map takes precedence.

# main.tf

variable "base_config" {
  type = map(string)
  default = {
    region = "us-east-1"
    env    = "dev"
  }
}

variable "override_config" {
  type = map(string)
  default = {
    env  = "prod"
    size = "large"
  }
}

output "merged_full_config" {
  description = "Merged configuration, with overrides."
  value       = merge(var.base_config, var.override_config, {"owner" = "platform-team"})
  # Outcome: { "region" = "us-east-1", "env" = "prod", "size" = "large", "owner" = "platform-team" }
}

Explanation:

  • merge is extremely useful for building composite configurations, combining default settings with environment-specific overrides, or stacking configurations from multiple modules.

Transforming and Filtering with For Expressions

The for expression is incredibly powerful for manipulating maps. You can iterate over map entries, transform keys or values, filter entries, and create new maps or lists.

Transforming Map Values

# main.tf

variable "instance_sizes" {
  type = map(string)
  default = {
    dev  = "t2.micro"
    prod = "t3.medium"
  }
}

output "instance_amis" {
  description = "Map of instance types to specific AMIs."
  value = {
    for env, size in var.instance_sizes :
    env => "${size}-ami-latest" # Transform value
  }
  # Outcome: { "dev" = "t2.micro-ami-latest", "prod" = "t3.medium-ami-latest" }
}

Explanation:

  • This for expression iterates over each key-value pair (env, size) in instance_sizes and creates a new map where the keys are the same, but the values are transformed strings.

Filtering Map Entries

# main.tf

variable "security_rules" {
  type = map(string)
  default = {
    http  = "80"
    https = "443"
    ssh   = "22"
    ftp   = "21"
  }
}

output "common_ports" {
  description = "Map containing only common HTTP/HTTPS/SSH ports."
  value = {
    for name, port in var.security_rules : name => port
    if contains(["80", "443", "22"], port) # Filter based on value
  }
  # Outcome: { "http" = "80", "https" = "443", "ssh" = "22" }
}

Explanation:

  • This for expression iterates over security_rules and includes key-value pairs in the new map only if the port value is one of the specified common ports.

Practical Use Cases

  • for_each with Resource Creation: Maps are the ideal input for for_each meta-argument, allowing you to create multiple instances of a resource where each instance is uniquely identified by a map key and configured by its corresponding value. This is superior to count for managing individual resource lifecycles.
  • Dynamic Tags: Define all your resource tags as a map and then use merge to combine global tags with resource-specific tags, passing the resulting map to the tags argument of resources.
  • Complex Nested Configurations: Represent complex configurations, like network interfaces with multiple IP addresses, as objects within maps or nested maps, allowing for structured and readable data.
  • Module Configuration: Define configurable options for your modules as map or object variables, making modules highly reusable and adaptable to different scenarios.

Best Practices for Map and Object Manipulation

  • Choose Wisely: Use maps/objects when you need named attributes and associative access (accessing by key). Use lists when order matters and you need numerically indexed access. Use sets when only uniqueness matters and order is irrelevant.
  • Using for_each: For creating multiple resources where each needs a unique identity and potentially different attributes, use for_each with a map. This greatly simplifies managing resource lifecycles compared to count.
  • Handle Missing Keys: Always assume keys might be missing if inputs are dynamic. Use lookup with default values or try expressions to prevent runtime errors.
  • Readability: For very complex map manipulations, break them down into smaller local values. This improves clarity and debugging.
  • Schema Definition with object: For module inputs or complex data structures, explicitly define object types for variables to enforce schema, improve validation, and provide better documentation.
  • Keys as Identifiers: When using maps for for_each, ensure your keys are unique and stable identifiers for the resources you are creating.

Conclusion

Mastering map and object manipulation is an important skill required for almost everyday in a terraform development lifecycle . By effectively leveraging map definitions, attribute access methods like dot/bracket notation and lookup/try, and the powerful for expressions for transformation and filtering, you gain great flexibility in your infrastructure definitions. These capabilities are required for handling dynamic configurations, managing resource attributes, and using for_each based resource provisioning, ultimately leading to more modular, scalable, and maintainable Infrastructure as Code.

Author

Debjeet Bhowmik

Experienced Cloud & DevOps Engineer with hands-on experience in AWS, GCP, Terraform, Ansible, ELK, Docker, Git, GitLab, Python, PowerShell, Shell, and theoretical knowledge on Azure, Kubernetes & Jenkins.
In my free time, I write blogs on ckdbtech.com

Leave a Comment