| 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.
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.
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/basic-optional
cd ~/terraform-labs/terraform-optional-object-attributes/demos/basic-optionalDefine one required and one optional field:
cat > variables.tf <<'EOF'
variable "config" {
type = object({
name = string
profile = optional(string)
})
}
EOFPass the object through a terraform_data resource and an output so the plan shows the resolved value:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "config" {
input = var.config
}
output "config" {
value = var.config
}
EOFInitialize the working directory:
terraform init -input=falsePlan with only the required name key supplied:
terraform plan -no-color -input=false -var='config={ name = "app1" }'# 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:
terraform plan -no-color -input=false -var='config={ name = "app1", profile = "prod" }'+ 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:
profile = optional(string, "default")Create a sibling directory with that shape:
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/default-value
cd ~/terraform-labs/terraform-optional-object-attributes/demos/default-valueWrite the variable type with a default on profile:
cat > variables.tf <<'EOF'
variable "config" {
type = object({
name = string
profile = optional(string, "default")
})
}
EOFAdd main.tf to surface the resolved object in plan output:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "config" {
input = var.config
}
output "config" {
value = var.config
}
EOFInitialize the directory:
terraform init -input=falsePlan without sending profile:
terraform plan -no-color -input=false -var='config={ name = "app1" }'+ 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:
terraform plan -no-color -input=false -var='config={ name = "app1", profile = "staging" }'+ 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.
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/nested-optional
cd ~/terraform-labs/terraform-optional-object-attributes/demos/nested-optionalWrite the nested optional object type:
cat > variables.tf <<'EOF'
variable "config" {
type = object({
name = string
settings = optional(object({
replicas = optional(number, 1)
labels = optional(map(string), {})
}), {})
})
}
EOFAdd the pass-through resource and output:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "config" {
input = var.config
}
output "config" {
value = var.config
}
EOFThe 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:
terraform init -input=falsePass only name on the command line and read how nested defaults fill in:
terraform plan -no-color -input=false -var='config={ name = "svc" }'+ 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:
terraform plan -no-color -input=false -var='config={ name = "svc", settings = { replicas = 3 } }'+ 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:
terraform plan -no-color -input=false -var='config={ name = "svc", settings = null }'+ 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:
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/missing-vs-null
cd ~/terraform-labs/terraform-optional-object-attributes/demos/missing-vs-nullWrite the variable type:
cat > variables.tf <<'EOF'
variable "config" {
type = object({
name = string
profile = optional(string, "default")
})
}
EOFAdd main.tf for plan output:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "config" {
input = var.config
}
output "config" {
value = var.config
}
EOFInitialize and plan without profile:
terraform init -input=falseOmit profile entirely and confirm the default appears in the plan:
terraform plan -no-color -input=false -var='config={ name = "app1" }'+ 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:
terraform plan -no-color -input=false -var='config={ name = "app1", profile = null }'+ 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:
cd ~/terraform-labs/terraform-optional-object-attributes/demos/basic-optionalCompare the plan line for profile:
terraform plan -no-color -input=false -var='config={ name = "app1", profile = null }'+ 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.
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/map-object
cd ~/terraform-labs/terraform-optional-object-attributes/demos/map-objectDefine the map-of-objects variable:
cat > variables.tf <<'EOF'
variable "instances" {
type = map(object({
name = string
size = optional(string, "small")
enabled = optional(bool, true)
}))
}
EOFUse for_each = var.instances so the plan lists one resource per key:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "instances" {
for_each = var.instances
input = each.value
}
EOFInitialize and plan with two entries where the second overrides optional fields:
terraform init -input=falseFeed a map where entry a omits optional keys and entry b overrides them:
terraform plan -no-color -input=false -var='instances={ a = { name = "one" }, b = { name = "two", size = "large", enabled = false } }'# 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.
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/demos/tfvars-override
cd ~/terraform-labs/terraform-optional-object-attributes/demos/tfvars-overrideWrite the variable type with note having no default:
cat > variables.tf <<'EOF'
variable "config" {
type = object({
name = string
profile = optional(string, "default")
note = optional(string)
})
}
EOFAdd the pass-through resource:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "config" {
input = var.config
}
output "config" {
value = var.config
}
EOFterraform.tfvars omits profile and sets note to null:
cat > terraform.tfvars <<'EOF'
config = {
name = "from-tfvars"
note = null
}
EOFInitialize and plan with no extra flags so tfvars load automatically:
terraform init -input=falseRun plan with no -var flags so terraform.tfvars supplies the object:
terraform plan -no-color -input=false+ 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:
terraform plan -no-color -input=false -var='config={ name = "cli", profile = "override", note = "supplied" }'+ 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:
terraform plan -no-color -input=false -var='config={ name = "cli" }'+ 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.
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/errors/incompatible-default
cd ~/terraform-labs/terraform-optional-object-attributes/errors/incompatible-defaultUse a string default on a number attribute:
cat > variables.tf <<'EOF'
variable "config" {
type = object({
count = optional(number, "not-a-number")
})
}
EOFAdd a minimal root module so Terraform can load the configuration:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
EOFInitialize — Terraform loads the variable type and reports the mismatch:
terraform init -input=falseError: 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:
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/errors/incompatible-default-object
cd ~/terraform-labs/terraform-optional-object-attributes/errors/incompatible-default-objectDefault to { x = "wrong" } when x must be a number:
cat > variables.tf <<'EOF'
variable "config" {
type = object({
settings = optional(object({
x = number
}), { x = "wrong" })
})
}
EOFAdd a minimal root module:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
EOFInitialize again and read the nested-object default error:
terraform init -input=falseError: 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.
mkdir -p ~/terraform-labs/terraform-optional-object-attributes/errors/null-parent
cd ~/terraform-labs/terraform-optional-object-attributes/errors/null-parentDefine a required nested settings object:
cat > variables.tf <<'EOF'
variable "config" {
type = object({
name = string
settings = object({
replicas = number
})
})
}
EOFAdd a resource that reads the whole config object:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "config" {
input = var.config
}
EOFInitialize the directory:
terraform init -input=falsePlan without a settings block and confirm Terraform rejects the input:
terraform plan -no-color -input=false -var='config={ name = "x" }'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:
terraform plan -no-color -input=false -var='config={ name = "x", settings = null }'# 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
- Type constraints for variables — HashiCorp Terraform language docs
- Optional object type attributes —
optional()syntax and defaults
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.

