Chapter 7: Complex Terraform Data Types

Complex data types

Primitive data types in Terraform, such as strings, numbers, and booleans, are often sufficient for many use cases. These data types are simple and can be used to represent basic values like names, numbers, and true/false values. However, there may be situations where you need to represent more complex data structures in your Terraform configuration, such as lists of objects or nested maps. For these cases, you can use complex data types like lists, maps, and objects. Complex data types allow you to represent more advanced data structures and can be useful in situations where you need to repeat a block of code multiple times, nest data structures, or validate the data type and structure of a variable.

Let's list a few reasons why you might need to use complex data types in Terraform with examples:

Repetition: If you need to repeat a block of code multiple times, with each iteration having slightly different values, you can use lists or maps to represent the data for each iteration. For example, suppose you want to create multiple Azure resource groups, with each resource group having a different name and location. You can use a list or map to store the names and locations of each resource group, and then use the count or for_each arguments to iterate over the list or map and create the resource groups. The count argument allows you to specify the number of times a block of code should be repeated, while the for_each argument allows you to iterate over a map or list and access the values for each element.

Here is an example of using a list to create multiple Azure resource groups with different names and locations with count:

# List of resource group names and locations
variable "resource_groups" {
  type = list(object({
    name     = string
    location = string
  }))
  default = [
    {
      name     = "rg-exmaple-neu-001"
      location = "north europe"
    },
    {
      name     = "rg-exmaple-weu-001"
      location = "west europe"
    }
  ]
}

resource "azurerm_resource_group" "example" {
  count = length(var.resource_groups)

  name     = var.resource_groups[count.index].name
  location = var.resource_groups[count.index].location
}

The same example can also be done by a for_each loop as below with a map(object) type variable:

variable "resource_groups2" {
  type = map(object({
    location = string
  }))
  default = {
    "rg-exmaple-neu-002" = {
      location = "north europe"
    },
    "rg-exmaple-weu-002" = {
      location = "west europe"
    },
  }
}

resource "azurerm_resource_group" "example2" {
  for_each = var.resource_groups2

  name     = each.key
  location = each.value.location
}

Nesting: If you need to represent data structures that are nested, such as a list of maps or an object within an object, you can use complex data types like lists, maps, and objects. For example, suppose you want to create a list of Azure virtual machines, where each virtual machine has its own name, location, size, and data disk layout. You can use a list of objects to represent this data structure, with each object containing the name, location, size, and data disk of a single virtual machine. This allows you to nest the data for each virtual machine within the list, making it easy to access and manage.

# Map of objects for virtual machines
variable "vms" {
  type = map(object({
    location            = string
    resource_group_name = string
    data_disks = list(object({
      name         = string
      disk_size_gb = number
    }))
  }))
  default = {
    "exmaple-vm-001" = {
      location            = "north-europe"
      resource_group_name = "rg-example-neu-001"
      data_disks = [
        {
          disk_size_gb = 100
          name         = "data-disk-001"
        },
        {
          disk_size_gb = 200
          name         = "data-disk-002"
        }
      ]
    },
    "exmaple-vm-002" = {
      location            = "west-europe"
      resource_group_name = "rg-example-weu-001"
      data_disks = [
        {
          disk_size_gb = 100
          name         = "data-disk-001"
        }
      ]
    }
  }
}

Validation: Complex data types can also be useful for validation, as you can use the type argument to specify the exact data type and structure that is expected for a given variable. This can help ensure that your configuration is syntactically correct and that you are using the correct data types for your resources. For example, if you specify that a variable is a list of strings, Terraform will validate that the values in the list are strings and will return an error if any values are of a different type. This can help prevent errors and ensure that your configuration is correct before you apply it.

Dynamic Blocks

In Terraform, we organize our infrastructure using blocks. These blocks can contain other blocks, and the number of blocks can be dynamic depending on the requirements. As an example, consider the azurerm_virtual_network resource. The official documentation states that the subnet argument can be specified multiple times to create multiple subnets.

Below is an example taken from the official documentation of the azurerm_virtual_network resource, with a few minor modifications.

resource "azurerm_virtual_network" "example" {
  name                = "example-network"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
  address_space       = var.adress_space
  dns_servers         = var.dns_servers

  subnet {
    name           = var.subnet1.name
    address_prefix = var.subnet1.address_prefix 
  }

  subnet {
    name           = var.subnet2_name
    address_prefix = var.subnet2_address_prefix 
  }
}

In this example, we have defined two subnets. But what if we need to have two subnets in our development environment and three subnets in our production environment? This is where dynamic blocks come in handy. With dynamic blocks, you can provide the flexibility to have at least one subnet, but also allow for additional subnets to be added as needed. You can implement this functionality using dynamic blocks in the following way:

provider "azurerm" {
  features {}
}

variable "address_space" {
  type    = list(string)
  default = ["10.0.0.0/16"]
}

variable "dns_servers" {
  type    = list(string)
  default = ["10.0.0.4", "10.0.0.5"]
}

resource "azurerm_resource_group" "example" {
  name     = "rg-dynamic-blocks-001"
  location = "North Europe"
}

variable "default_subnet_name" {
  type    = string
  default = "subnet1"
}

variable "default_subnet_address_prefix" {
  type    = string
  default = "10.0.1.0/24"
}

variable "additional_subnets" {
  description = "list of values to assign to subnets"
  type = list(object({
    name           = string
    address_prefix = string
  }))
  default = []
}

resource "azurerm_virtual_network" "example" {
  name                = "example-network"
  location            = azurerm_resource_group.example.location
  resource_group_name = azurerm_resource_group.example.name
  address_space       = var.address_space
  dns_servers         = var.dns_servers

  # Required subnet
  subnet {
    name           = var.default_subnet_name
    address_prefix = var.default_subnet_address_prefix
  }

  # Optinal subnet(s)
  dynamic "subnet" {
    for_each = var.additional_subnets
    iterator = subnet
    content {
      name           = subnet.value.name
      address_prefix = subnet.value.address_prefix
    }
  }
}

In this example, we establish a virtual network and within that network resource, we define one mandatory subnet and any additional optional subnets using a loop with a dynamic block. This approach allows us to create different numbers of subnets per environment while using the same resource structure, which makes the code more readable for reviewers. Additionally, by keeping the default value as "none", we can skip providing a value to the "additional_subnets" variable if we do not intend to create any additional subnets.

You may find yourself using dynamic blocks for almost all loops, but we should be careful not to overuse them as it can reduce the readability of our code. Please refer to When to Write a Module and Module Composition articles for more information.

Last updated