skip to content

In Terraform 1.3 or later, how do you declare a module input that takes an object where some attributes may be omitted by the caller, and what value do the omitted attributes get?

level: middleimportance: should knowfreq 46%

answer

  1. wrap the type, not the attribute
  2. two-argument form supplies the default
  3. omitted with no default means null
  4. applied during type conversion
  5. fills in nested collections too

basics

~10 s

Wrap the attribute's type in optional() inside the object type constraint. An omitted attribute declared optional(string) becomes null; optional(string, "t3.micro") supplies that default instead. Required attributes stay unwrapped and must be given.

solid answer

~50 s

Inside an `object({...})` type constraint you mark an attribute optional by wrapping its type: `optional(string)` or `optional(string, "t3.micro")`. An attribute the caller omits is then `null` in the one-argument form, or the supplied default in the two-argument form; attributes left unwrapped remain required and their absence fails the plan. Terraform applies these defaults as part of the type conversion, and it does so recursively — including to objects nested inside a collection in the constraint, so each element of a `list(object({...}))` gets its own defaults filled in. This is the modern way to give a module a rich but forgiving interface: one well-typed variable describing a whole subsystem, where the caller supplies three attributes and the other ten fill themselves in. Before 1.3 the alternatives were a flat wall of separate variables, or `any` plus manual `lookup`/`coalesce` gymnastics with no type checking at all.

code

hcl · 14 lines
hcl
variable "service" {
  type = object({
    name     = string
    image    = string
    replicas = optional(number, 2)
    cpu      = optional(number, 256)
    env      = optional(map(string), {})
    role_arn = optional(string)
  })
}

output "resolved" {
  value = var.service
}

go deeper

for a junior

Recall the shape: inside object({...}) you wrap an attribute's type as optional(string) or optional(string, "default"), and everything unwrapped is required.

for a middle

Explain that defaults are applied as part of the type conversion and recursively into nested objects, and that a one-argument optional yields null rather than an empty value.

for a senior

Show the judgment: which attributes genuinely have no safe default and must stay required, and why null rather than an empty string is the right omission value for pass-through provider arguments.

for a principal

Own the contract question — an object-typed input is checked as a unit, so plan how the shape evolves: additive optional attributes are safe, anything else is a major version with a migration note.

## The problem optional attributes solve A module that configures something substantial — a database, a service, a network — has a lot of knobs. Two bad shapes used to compete for that job. One was the flat wall: twenty separate `variable` blocks, all with defaults, that a reader has to mentally re-assemble into the structure they represent. The other was a single variable typed `any` (or `map(any)`), where the module dug values out with `lookup(var.config, "engine", "postgres")` — flexible, entirely unchecked, and documented only by reading the module body. Object types with optional attributes give you the third option: one variable whose *type* is the documentation, most of whose attributes the caller can ignore. ## The syntax ```hcl variable "database" { type = object({ engine = string size_gb = number instance_class = optional(string, "db.t3.micro") multi_az = optional(bool, false) parameters = optional(map(string), {}) kms_key_arn = optional(string) }) } ``` `optional(T)` marks the attribute as omittable with no default; `optional(T, default)` supplies one. `engine` and `size_gb` are unwrapped, so they are required and omitting either fails the plan with an error naming the missing attribute. A caller can now write: ```hcl module "db" { source = "./modules/db" database = { engine = "postgres" size_gb = 100 } } ``` and the module sees `instance_class = "db.t3.micro"`, `multi_az = false`, `parameters = {}` and `kms_key_arn = null`. ## Null is a real value here The one-argument `optional(string)` deliberately yields `null` rather than an empty string. That matters because `null` for a resource argument means "I am not setting this" — so `kms_key_arn = var.database.kms_key_arn` passes straight through and, when the caller omitted it, behaves exactly as if the argument had not been written. An empty string would not: it is a value, and the provider would try to use it. ## Defaults are applied recursively The defaults are part of the type conversion, not a runtime lookup, and Terraform applies them throughout the constraint — including to object types nested inside collections: ```hcl variable "buckets" { type = map(object({ versioning = optional(bool, true) prefix = optional(string, "") })) } ``` Each entry of the map gets its own defaults filled in, so a caller can write `{ logs = {}, assets = { versioning = false } }` and both entries come out fully populated. Getting the same effect by hand with `merge` over a `map(any)` is possible and considerably worse to read. ## Where it sits in a module's interface The practical guidance is to reserve required, unwrapped attributes for the things that genuinely have no sensible default — the ones where guessing would be wrong — and make the rest optional with the default the module would have chosen anyway. That gives a two-line call for the common case and a fully specified one for the unusual case, from a single declaration that also serves as the module's documentation. The cost to be aware of: the object type is a contract. Adding a new `optional` attribute with a default is backwards-compatible for existing callers; adding a required one, renaming an attribute, or changing an attribute's type is a breaking change to every caller at once, because the whole value is checked as a unit. ## Version note Optional object type attributes and their defaults became generally available in Terraform 1.3. Earlier versions had an opt-in language experiment with different syntax and no default argument, so module code written for 1.3 and later will not work on an older CLI — which is a reason for such a module to declare a `required_version` constraint.

  • What is the difference between optional(string) and optional(string, "") for an attribute the caller omits?
    `optional(string)` yields `null`, which means "not set" — passed to a resource argument it behaves as if the argument were absent. `optional(string, "")` yields an empty string, which is a real value the provider will act on. For anything that maps onto an optional provider argument, the null form is almost always what you want.
  • Is adding a new attribute to a published module's object-typed variable a breaking change?
    It depends on the form. Adding an `optional` attribute with a default is compatible — existing callers omit it and get the default. Adding a required attribute, renaming one, or changing an attribute's type breaks every caller immediately, because the whole object is checked as a unit at plan time. Those belong behind a major version.
  • Why prefer an object with optional attributes over a variable typed map(any) with lookup() calls in the module?
    Because the object type is checked and self-documenting. A caller's typo in an attribute name or a wrong-typed value is caught during variable evaluation, and the type constraint tells a reader the whole interface. With `map(any)` plus `lookup`, the same mistake surfaces as a confusing failure deep inside a resource, or silently as the fallback value.

saying these in an interview costs you the question

  • Writing optional = true as an argument instead of wrapping the type
  • Expecting an omitted optional attribute to be an empty string
  • Thinking defaults on nested objects inside a collection are not applied
  • Assuming optional() with defaults works on any Terraform version
  • Believing unwrapped attributes are optional too

context