Terraform Optional Object Attributes and Defaults

Tested on Ubuntu 26.04 LTS (Resolute Raccoon)
Package terraform 1.15.8-1
Applies to Any host with Terraform installed
Lab environment Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu
Privilege Normal user
Scope Terraform optional() inside object and map(object) type constraints — required vs optional attributes, per-attribute defaults, nested optional objects, omitted keys vs explicit null, tfvars and -var overrides, and validate-time default type errors. Does not replace the full Terraform data types catalog or every variable validation pattern.
Related guides Terraform data types
Terraform variables
Module inputs and outputs
terraform validate
terraform plan

Module variables with type = object({ ... }) force callers to supply every key unless you mark attributes with optional(). That lets you accept partial configuration: omit keys you do not care about, fill sensible defaults for the rest, and keep a strict schema instead of falling back to any.

Each walkthrough uses its own directory under ~/terraform-labs/terraform-optional-object-attributes/. Examples use terraform_data only so you can plan without cloud credentials.

NOTE
optional() requires Terraform 1.3 or later. Run terraform init once in each new demo directory before terraform plan.

Basic optional() attribute

A plain string field inside an object type is required. Wrap it with optional(string) and callers can leave the key out.

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/basic-optional
cd ~/terraform-labs/terraform-optional-object-attributes/demos/basic-optional

Define one required and one optional field:

bash
cat > variables.tf <<'EOF'
variable "config" {
  type = object({
    name    = string
    profile = optional(string)
  })
}
EOF

Pass the object through a terraform_data resource and an output so the plan shows the resolved value:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "config" {
  input = var.config
}

output "config" {
  value = var.config
}
EOF

Initialize the working directory:

bash
terraform init -input=false

Plan with only the required name key supplied:

bash
terraform plan -no-color -input=false -var='config={ name = "app1" }'
output
# terraform_data.config will be created
  + resource "terraform_data" "config" {
      + input = {
          + name    = "app1"
          + profile = null
        }
    }

Changes to Outputs:
  + config = {
      + name    = "app1"
      + profile = null
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Omitting profile is valid, and Terraform sets it to null because optional(string) has no default.

Plan again with profile supplied:

bash
terraform plan -no-color -input=false -var='config={ name = "app1", profile = "prod" }'
output
+ resource "terraform_data" "config" {
      + input = {
          + name    = "app1"
          + profile = "prod"
        }
    }

Changes to Outputs:
  + config = {
      + name    = "app1"
      + profile = "prod"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Supplying the key passes your value through unchanged.


Add a default value

Pass a second argument to optional() when omission should resolve to a concrete default instead of null:

hcl
profile = optional(string, "default")

Create a sibling directory with that shape:

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/default-value
cd ~/terraform-labs/terraform-optional-object-attributes/demos/default-value

Write the variable type with a default on profile:

bash
cat > variables.tf <<'EOF'
variable "config" {
  type = object({
    name    = string
    profile = optional(string, "default")
  })
}
EOF

Add main.tf to surface the resolved object in plan output:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "config" {
  input = var.config
}

output "config" {
  value = var.config
}
EOF

Initialize the directory:

bash
terraform init -input=false

Plan without sending profile:

bash
terraform plan -no-color -input=false -var='config={ name = "app1" }'
output
+ resource "terraform_data" "config" {
      + input = {
          + name    = "app1"
          + profile = "default"
        }
    }

Changes to Outputs:
  + config = {
      + name    = "app1"
      + profile = "default"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

The plan shows "default" even though the caller never sent profile.

Override the default by supplying a different string:

bash
terraform plan -no-color -input=false -var='config={ name = "app1", profile = "staging" }'
output
+ resource "terraform_data" "config" {
      + input = {
          + name    = "app1"
          + profile = "staging"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Caller-supplied values always win over the optional() default.


Nested optional objects

HashiCorp supports optional() on nested object attributes, including defaults on the parent object and on inner keys. That pattern fits module inputs like service settings where most callers want a small default block.

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/nested-optional
cd ~/terraform-labs/terraform-optional-object-attributes/demos/nested-optional

Write the nested optional object type:

bash
cat > variables.tf <<'EOF'
variable "config" {
  type = object({
    name = string
    settings = optional(object({
      replicas = optional(number, 1)
      labels   = optional(map(string), {})
    }), {})
  })
}
EOF

Add the pass-through resource and output:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "config" {
  input = var.config
}

output "config" {
  value = var.config
}
EOF

The outer optional(object({ ... }), {}) means callers can omit settings entirely; Terraform substitutes an empty object, then inner defaults fill replicas and labels.

Initialize and plan with only name:

bash
terraform init -input=false

Pass only name on the command line and read how nested defaults fill in:

bash
terraform plan -no-color -input=false -var='config={ name = "svc" }'
output
+ resource "terraform_data" "config" {
      + input = {
          + name     = "svc"
          + settings = {
              + labels   = {}
              + replicas = 1
            }
        }
    }

Changes to Outputs:
  + config = {
      + name     = "svc"
      + settings = {
          + labels   = {}
          + replicas = 1
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Nested defaults stack: missing parent key, then missing inner keys.

Send a partial settings object to override one inner field:

bash
terraform plan -no-color -input=false -var='config={ name = "svc", settings = { replicas = 3 } }'
output
+ input = {
      + name     = "svc"
      + settings = {
          + labels   = {}
          + replicas = 3
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

replicas came from the caller; labels still defaulted to {}.

Set settings = null on an optional parent object and Terraform applies the parent default {} before inner defaults run:

bash
terraform plan -no-color -input=false -var='config={ name = "svc", settings = null }'
output
+ settings = {
      + labels   = {}
      + replicas = 1
    }

Plan: 1 to add, 0 to change, 0 to destroy.

null on an optional attribute with a default object does not leave the field null; it triggers the default object, then inner defaults.


Missing attribute vs null

Omitting a key and setting it to null are not always the same. The outcome depends on whether the attribute has an optional() default.

Shape Caller omits key Caller sets key to null
optional(string) null null
optional(string, "default") "default" "default"
optional(string) (no default), set in tfvars null null

demos/missing-vs-null/ uses the same optional(string, "default") shape as default-value. Create it in a fresh directory:

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/missing-vs-null
cd ~/terraform-labs/terraform-optional-object-attributes/demos/missing-vs-null

Write the variable type:

bash
cat > variables.tf <<'EOF'
variable "config" {
  type = object({
    name    = string
    profile = optional(string, "default")
  })
}
EOF

Add main.tf for plan output:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "config" {
  input = var.config
}

output "config" {
  value = var.config
}
EOF

Initialize and plan without profile:

bash
terraform init -input=false

Omit profile entirely and confirm the default appears in the plan:

bash
terraform plan -no-color -input=false -var='config={ name = "app1" }'
output
+ profile = "default"

Changes to Outputs:
  + config = {
      + name    = "app1"
      + profile = "default"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Plan with an explicit null:

bash
terraform plan -no-color -input=false -var='config={ name = "app1", profile = null }'
output
+ profile = "default"

Changes to Outputs:
  + config = {
      + name    = "app1"
      + profile = "default"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

With a default on optional(), explicit null does not bypass the default. Both omission and null resolve to "default".

Return to demos/basic-optional/ (no default on profile) and set profile to null explicitly:

bash
cd ~/terraform-labs/terraform-optional-object-attributes/demos/basic-optional

Compare the plan line for profile:

bash
terraform plan -no-color -input=false -var='config={ name = "app1", profile = null }'
output
+ profile = null

Changes to Outputs:
  + config = {
      + name    = "app1"
      + profile = null
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Use optional(type) without a default when null must remain a meaningful value for your module logic.


Optional objects in map(object(...))

optional() works inside the object type of a map, so each map entry can omit keys independently.

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/map-object
cd ~/terraform-labs/terraform-optional-object-attributes/demos/map-object

Define the map-of-objects variable:

bash
cat > variables.tf <<'EOF'
variable "instances" {
  type = map(object({
    name    = string
    size    = optional(string, "small")
    enabled = optional(bool, true)
  }))
}
EOF

Use for_each = var.instances so the plan lists one resource per key:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "instances" {
  for_each = var.instances
  input    = each.value
}
EOF

Initialize and plan with two entries where the second overrides optional fields:

bash
terraform init -input=false

Feed a map where entry a omits optional keys and entry b overrides them:

bash
terraform plan -no-color -input=false -var='instances={ a = { name = "one" }, b = { name = "two", size = "large", enabled = false } }'
output
# terraform_data.instances["a"] will be created
  + resource "terraform_data" "instances" {
      + input = {
          + enabled = true
          + name    = "one"
          + size    = "small"
        }
    }

  # terraform_data.instances["b"] will be created
  + resource "terraform_data" "instances" {
      + input = {
          + enabled = false
          + name    = "two"
          + size    = "large"
        }
    }

Plan: 2 to add, 0 to change, 0 to destroy.

Entry a picked up size = "small" and enabled = true from defaults; entry b used the supplied overrides.


Override defaults from tfvars and CLI

Terraform first selects the value for the entire input variable according to variable-source precedence. It then converts that selected object to the declared type and applies optional() defaults to omitted attributes.

A CLI -var='config=...' value replaces the config object supplied by terraform.tfvars; it does not merge individual object keys. Any keys omitted from the winning object can still receive their optional() defaults.

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/tfvars-override
cd ~/terraform-labs/terraform-optional-object-attributes/demos/tfvars-override

Write the variable type with note having no default:

bash
cat > variables.tf <<'EOF'
variable "config" {
  type = object({
    name    = string
    profile = optional(string, "default")
    note    = optional(string)
  })
}
EOF

Add the pass-through resource:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "config" {
  input = var.config
}

output "config" {
  value = var.config
}
EOF

terraform.tfvars omits profile and sets note to null:

bash
cat > terraform.tfvars <<'EOF'
config = {
  name = "from-tfvars"
  note = null
}
EOF

Initialize and plan with no extra flags so tfvars load automatically:

bash
terraform init -input=false

Run plan with no -var flags so terraform.tfvars supplies the object:

bash
terraform plan -no-color -input=false
output
+ input = {
      + name    = "from-tfvars"
      + note    = null
      + profile = "default"
    }

Changes to Outputs:
  + config = {
      + name    = "from-tfvars"
      + note    = null
      + profile = "default"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

profile was omitted in tfvars so the optional default applied; note was set to null explicitly and stays null because that attribute has no default.

CLI -var overrides tfvars for the whole config variable when you need a one-off value:

bash
terraform plan -no-color -input=false -var='config={ name = "cli", profile = "override", note = "supplied" }'
output
+ input = {
      + name    = "cli"
      + note    = "supplied"
      + profile = "override"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

The CLI object replaced the tfvars object entirely — from-tfvars and the tfvars note = null are gone.

A partial CLI object does not deep-merge with tfvars either. Plan with only name on the command line:

bash
terraform plan -no-color -input=false -var='config={ name = "cli" }'
output
+ input = {
      + name    = "cli"
      + note    = null
      + profile = "default"
    }

Changes to Outputs:
  + config = {
      + name    = "cli"
      + note    = null
      + profile = "default"
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Terraform took the CLI config object (name only), then applied optional() defaults for omitted profile. note is null because the CLI object did not include that key and optional(string) has no default — not because tfvars note = null was merged in.


Common errors

Most optional-object mistakes surface at terraform validate before you apply.

Default incompatible with the attribute type

The second argument to optional() must match the attribute type.

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/errors/incompatible-default
cd ~/terraform-labs/terraform-optional-object-attributes/errors/incompatible-default

Use a string default on a number attribute:

bash
cat > variables.tf <<'EOF'
variable "config" {
  type = object({
    count = optional(number, "not-a-number")
  })
}
EOF

Add a minimal root module so Terraform can load the configuration:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}
EOF

Initialize — Terraform loads the variable type and reports the mismatch:

bash
terraform init -input=false
output
Error: Invalid default value for optional attribute

  on variables.tf line 3, in variable "config":
   3:     count = optional(number, "not-a-number")

This default value is not compatible with the attribute's type constraint:
a number is required.

Fix the default literal or change the attribute type so they agree.

Nested object defaults must match the inner object shape:

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/errors/incompatible-default-object
cd ~/terraform-labs/terraform-optional-object-attributes/errors/incompatible-default-object

Default to { x = "wrong" } when x must be a number:

bash
cat > variables.tf <<'EOF'
variable "config" {
  type = object({
    settings = optional(object({
      x = number
    }), { x = "wrong" })
  })
}
EOF

Add a minimal root module:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}
EOF

Initialize again and read the nested-object default error:

bash
terraform init -input=false
output
Error: Invalid default value for optional attribute

  on variables.tf line 5, in variable "config":
   5:     }), { x = "wrong" })

This default value is not compatible with the attribute's type constraint:
a number is required.

Required nested object vs optional parent

When a nested object is required (no optional() wrapper), callers must supply the settings key.

bash
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/errors/null-parent
cd ~/terraform-labs/terraform-optional-object-attributes/errors/null-parent

Define a required nested settings object:

bash
cat > variables.tf <<'EOF'
variable "config" {
  type = object({
    name = string
    settings = object({
      replicas = number
    })
  })
}
EOF

Add a resource that reads the whole config object:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "config" {
  input = var.config
}
EOF

Initialize the directory:

bash
terraform init -input=false

Plan without a settings block and confirm Terraform rejects the input:

bash
terraform plan -no-color -input=false -var='config={ name = "x" }'
output
Error: Invalid value for input variable

  on variables.tf line 1:
   1: variable "config" {

Unsuitable value for var.config set using -var="config=...": attribute
"settings" is required.

Setting settings = null validates because null is allowed for object-typed attributes unless you constrain further:

bash
terraform plan -no-color -input=false -var='config={ name = "x", settings = null }'
output
# terraform_data.config will be created
  + resource "terraform_data" "config" {
      + input = {
          + name     = "x"
          + settings = null
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

If you want omission to mean “use defaults,” wrap the nested block in optional(object({ ... }), { ... }) like the nested demo earlier.

Using any where an explicit object is clearer

type = any disables the attribute-level defaults optional() provides. Prefer object({ ... }) with optional() when callers pass structured module inputs; you get validate-time checks on keys and types. See Terraform data types for when any still makes sense.

Symptom Likely cause Fix
Invalid default value for optional attribute Default literal wrong type or wrong object shape Match the default to the optional() type argument
attribute "settings" is required Nested object not wrapped in optional() Add optional(object({ ... }), {}) or require callers to pass the block
settings = null in plan but module expected defaults Parent object is required, not optional Wrap parent in optional() with a default object
Typos in object keys pass silently Variable uses any instead of object Replace with explicit object({ ... }) and optional()

References


Summary

optional() inside object({ ... }) and map(object({ ... })) type constraints lets module authors mark which keys callers may omit. Without a second argument, omitted keys become null; with a default argument, omission resolves to that default. Nested optional(object({ ... }), {}) stacks parent and child defaults so a single module input can accept anything from a bare name to a fully specified settings block.

The distinction that trips people most often is missing key versus explicit null. When optional() includes a default, both omission and null receive the default, not a true null value. When you need null to mean something different from “use the default,” leave off the default argument and branch on null in your module.

For multi-instance inputs, map(object({ ... })) with per-field optional() defaults keeps each map entry independent. Combine that with terraform.tfvars and -var overrides knowing that higher-precedence sources replace the entire variable value, then optional() fills omitted keys on the winning object. Run terraform validate early: incompatible default literals and required nested objects fail fast with clear messages.


Frequently Asked Questions

1. What does optional() do in a Terraform object type?

optional() marks an object attribute as not required in the input value. Callers can omit the key. Without a second argument the attribute becomes null when omitted; with a default argument Terraform fills that default instead.

2. Does explicit null override an optional() default?

No. When you write optional(string, "default"), both an omitted key and an explicit null value resolve to the default string. To allow null as a distinct value, use optional(string) without a default and handle null in your module logic.

3. Can optional() defaults be nested inside other objects?

Yes. You can wrap optional(object({ ... }), {}) around a nested object and mark inner attributes optional with their own defaults. Omitted parent keys receive the parent default object, then inner defaults apply to any inner keys still missing.

4. When should I use optional() instead of type = any?

Use optional() when you know the object shape and want callers to omit keys safely with documented defaults. Reserve any for values you treat as opaque or when the structure is not fixed; explicit object types catch typos and wrong types at validate time.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)