Terraform Optional Variables and Attributes — Using Null and Optional Flag

How to make Terraform variables optional in resources, especially for attributes in object variables?

If you use Terraform modules with other configuration automation tools (e.g. Terragrunt or Ansible) to build your infrastructure, you may need to write generic modules which expose all resource arguments as variables with the same or similar argument structure, for flexibility. However, sometimes when you write the generic modules for some complex Terraform resources, there are usually some arguments with special usage or complex object structure. For example, you may not want an optional variable to even appear in your Terraform plan while you need to expose it in the resource codes. This article will cover how to address these requirements in your Terraform modules.

How to make variables optional in resources (not set when no input is given)

In Terraform, there are some variables that you may want to be unset in a resource (not appearing in the plan) when no input values are provided to them. This is because the variables could cause the resource arguments to be triggered with expected values even though you set nonsense default values such as an empty string.

In this case, you can simply use the null value in the variable usage within the resource block.

For example, in the resource below, the variable “app_id” is an optional argument for the resource. By making the corresponding variable optional, you still need to set a default value, such as “”. However, an empty string will still cause the resource to use the app_id to link to an app. To solve the problem, you can simply set the value of the argument to null in an expression:

variable "app_id" {
  type    = "string"
  default = ""
}

resource "null_resource" "example" {
  name   = "example-resource"
  app_id = var.app_id == "" ? null : var.app_id
}

Terraform will treat any argument with a null as unset and nothing for this argument will be taken to the Terraform plan.

How to make object variables for block arguments optional

Some Terraform block arguments are optional in a resource. Consider you have defined an object variable for a block argument with the same attribute structure as below:

variable "example_block" {
  type = object({
    name    = string
    enabled = bool
  })
  default = null
}

If you want to make it optional in the resource, you can use the for_each argument as a switch in a dynamic block:

dynamic "example_block" {
  for_each = var.example_block == null ? [] : [1]
  content {
    name    = var.example_block.name
    enabled = var.example_block.enabled
  }
}

In this example, when no input is given to the “example_block” variable, the dynamic block will have an empty list (length is 0) for the for_each argument and the block will not be populated.

How to make object variable attributes for block arguments optional

We already know how to use null values to make variables optional from the sections above. But what if the attributes or sub-objects need to be optional for a block argument as well? There are two approaches to achieve this:

Using optional flag

Terraform introduced the “optional()” function in version 0.14 as an experimental feature. This feature forces the attribute default values to null, which means they will not be brought to the plan if no corresponding input is given.

By the time this article was published, it had not been added to the mainstream features, but Terraform does keep supporting and maintaining this feature in all other versions since 0.14. And because using the optional flag is a better approach to building neat variable codes, it is more recommended. If you do not expect to use any experimental feature, please skip this approach and proceed to the next one!

As this is experimental, you first need to declare an “experiments” tag in the terraform block:

terraform {
  experiments = [module_variable_optional_attrs]
}

Then, in your variable definition, use the “optional()” in the attribute definition. Taking the “ip_restriction” argument from Terraform’s “azurerm_linux_web_app” resource as an example, the variable can be defined as below:

variable "ip_restriction" {
  type = list(object({
    action                    = optional(string)
    ip_address                = optional(string)
    name                      = optional(string)
    priority                  = optional(number)
    service_tag               = optional(string)
    virtual_network_subnet_id = optional(string)
    headers = optional(object({
      x_azure_fdid      = optional(list(string))
      x_fd_health_probe = optional(bool)
      x_forwarded_for   = optional(list(string))
      x_forwarded_host  = optional(list(string))
    }))
  }))
  default = []
}

Since in the backstage Terraform assigns a null value to those attributes and sub-objects, you can simply use the “can()” function to check if the optional attributes are set, and use null to compare with the sub-objects, as below:

dynamic "ip_restriction" {
  for_each = var.ip_restriction
  content {
    action                    = can(ip_restriction.value["action"]) ? ip_restriction.value["action"] : null
    ip_address                = can(ip_restriction.value["ip_address"]) ? ip_restriction.value["ip_address"] : null
    name                      = can(ip_restriction.value["name"]) ? ip_restriction.value["name"] : null
    priority                  = can(ip_restriction.value["priority"]) ? ip_restriction.value["priority"] : null
    service_tag               = can(ip_restriction.value["service_tag"]) ? ip_restriction.value["service_tag"] : null
    virtual_network_subnet_id = can(ip_restriction.value["virtual_network_subnet_id"]) ? ip_restriction.value["virtual_network_subnet_id"] : null
    dynamic "headers" {
      for_each = ip_restriction.value["headers"] == null ? [] : [1]
      content {
        x_azure_fdid      = can(ip_restriction.value["headers"].x_azure_fdid) ? ip_restriction.value["headers"].x_azure_fdid : null
        x_fd_health_probe = can(ip_restriction.value["headers"].x_fd_health_probe) ? ip_restriction.value["headers"].x_fd_health_probe : null
        x_forwarded_for   = can(ip_restriction.value["headers"].x_forwarded_for) ? ip_restriction.value["headers"].x_forwarded_for : null
        x_forwarded_host  = can(ip_restriction.value["headers"].x_forwarded_host) ? ip_restriction.value["headers"].x_forwarded_host : null
      }
    }
  }
}

For more information about this feature, please refer to the documentation.

Decouple the attributes/sub-object from the object variable

An alternative to using the optional feature is to decouple the attributes or sub-objects from the complex object variable and make them individual variables. In this way, you can use the approach from the first section to make them optional in a block. Taking the same example from above, for the “action” attribute, you can simply define an individual variable as below:

variable "ip_restriction_action" {
  type    = string
  default = ""
}. . .
dynamic "ip_restriction" {
  for_each = var.ip_restriction
  content {
    action = var.ip_restriction_action == "" ? null : var.ip_restriction_action
  }
  . . .
}

The described method is a traditional and conservative approach that allows for full exposure of all attributes. However, it comes with the downside of requiring the definition of numerous variables, potentially leading to messy variable codes. Nonetheless, if your goal is to prioritize stability by avoiding experimental features or ensuring compatibility with older Terraform SDKs or versions, then this approach remains a suitable choice.