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?
answer
- wrap the type, not the attribute
- two-argument form supplies the default
- omitted with no default means null
- applied during type conversion
- fills in nested collections too
basics
~10 sWrap 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 sInside 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 linesvariable "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
Recall the shape: inside object({...}) you wrap an attribute's type as optional(string) or optional(string, "default"), and everything unwrapped is required.
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.
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.
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