In Terraform, what class of errors does the try() function actually catch, and when is lookup() or coalesce() the better choice?
answer
- only structural errors, not everything
- narrow the scope of the fallback
- missing key versus present-but-null
- boolean sibling returns true or false
- fix the type instead of the expression
basics
~20 stry() evaluates its arguments in order and returns the first that does not raise a dynamic type or traversal error - a missing attribute, a missing index, a failed conversion. It is not a general exception handler. Use lookup() for a missing map key with a default and coalesce() for a value that is present but null or empty.
solid answer
~50 s`try(a, b, c)` returns the first argument that evaluates without a *dynamic* error, and the errors it catches are narrow by design: attribute lookups on a value that lacks the attribute, indexes that are out of range or absent, and type conversions that fail. It is not a catch-all — a provider error, a failed API call or an invalid argument still fails the plan. That narrowness is the point, and the reason to use it sparingly: a `try` wrapped around a large expression will happily swallow a real typo and hand you the fallback, so you get a silently wrong config instead of an error. Pick the narrowest tool: `lookup(map, key, default)` when a map key may be absent, `coalesce(a, b)` when the value exists but may be null or an empty string, `can(expr)` when you want a boolean rather than a value. And prefer `optional()` defaults in the variable's type where the shape is yours to define.
code
hcl · 15 lineslocals {
cfg = { name = "web", tags = { env = "" } }
# missing attribute: try catches the traversal error
timeout = try(local.cfg.timeout, 30) # => 30
# missing map key with a default
owner = lookup(local.cfg.tags, "owner", "unassigned") # => "unassigned"
# present but empty string: coalesce, not try
env = coalesce(local.cfg.tags["env"], "dev") # => "dev"
# boolean form
has_name = can(local.cfg.name) # => true
}go deeper
Know that try returns the first argument that works and is typically used to supply a fallback when an optional attribute may be absent, and that lookup can take a default for a missing map key.
State the boundary precisely: try catches attribute, index and conversion errors only, not provider or argument errors. Explain why coalesce skips empty strings as well as null, and what can() returns.
Show that you scope fallbacks narrowly and can name what a wide try hides — a silently wrong deploy instead of a failed plan. Distinguish null, empty and unknown, and prefer normalising the input type over patching every expression downstream.
Own the input contract. Every try in a shared module is an undocumented default; pushing those into declared optional fields with explicit defaults makes the module's behaviour reviewable and keeps genuinely invalid input failing loudly rather than deploying something plausible.
## What try actually does `try` takes one or more expressions and returns the result of the first one that succeeds: ```hcl try(var.config.timeout, 30) try(local.parsed.settings["region"], var.default_region, "us-east-1") ``` The documented restriction is that it can only catch **dynamic errors relating to data structure** — that is: - accessing an attribute that the value does not have; - indexing a list, map or tuple with a key or position that is not there; - a type conversion that cannot be performed. Everything else is still a hard failure. A function called with the wrong number of arguments, a provider rejecting an argument, a reference to a name that does not exist in the configuration at all — these are static or provider-level problems that `try` cannot and does not intercept. ## Why "use it sparingly" is real advice, not boilerplate The danger is scope. Compare: ```hcl try(var.cfg.database.port, 5432) # narrow try(jsondecode(file(var.path)).db.port, 5432) # wide ``` The second swallows a missing file, malformed JSON, a renamed key, and a typo in `db` — all as "use 5432". You deploy against the wrong port and Terraform reports success. When the fallback path is a legitimate outcome, keep the `try` around the smallest traversal that can legitimately be absent. When absence means the input is wrong, do not use `try` at all: let it fail, or reject it up front so the error names the real problem. ## The neighbouring functions, and when each fits - **`lookup(map, key, default)`** — the value may be a *missing key in a map*. With a default supplied, the missing key returns the default; without one, a missing key is an error. Where the key is a literal you know at write time, index syntax `local.m["key"]` is clearer and the documentation prefers it; `lookup`'s reason to exist is the default and the dynamically computed key. - **`coalesce(a, b, ...)`** — the value *exists* but may be null or an empty string. Coalesce returns the first argument that is neither. That "empty string counts as absent" rule is exactly what you want for user-supplied strings and exactly what surprises people who expected only null-checking. `coalescelist` is the list-shaped sibling. - **`can(expr)`** — returns `true` or `false` rather than a value, catching the same narrow class of errors as `try`. It answers "would this expression work?" and is the right tool when you want a boolean for a conditional rather than a fallback value. - **`one(list)`** — returns the single element of a one-element list, `null` for an empty list, and errors on more. Cleaner than `try(list[0], null)` when "zero or one" is the real contract. ## Prefer fixing the type over patching the expression A lot of `try` in real repositories exists because the input variable's type is loose — `any`, or an object whose optional fields were never declared. Terraform's `optional()` modifier inside an object type constraint lets you declare a field as optional and give it a default, so the value is already normalised before any expression touches it: ```hcl variable "cfg" { type = object({ name = string timeout = optional(number, 30) }) } ``` Every downstream `try(var.cfg.timeout, 30)` disappears. When you own the interface, this is strictly better: the default is documented in one place, and a genuinely wrong input still produces a real error instead of quietly taking the fallback. ## Null, empty and unknown are three different things A senior answer distinguishes them: - **null** means "argument omitted" — assigning null to a resource argument is the same as not setting it, which lets the provider apply its own default. - **empty** (`""`, `[]`, `{}`) is a real value that is present and blank. `coalesce` treats an empty string as absent; a plain `!= null` check does not. - **unknown** — `(known after apply)` — is a value that will exist but is not computed yet at plan time. `try` and `can` do not resolve unknowns: an unknown propagates through them, and a conditional whose predicate is unknown makes the whole result unknown. This is why a config full of defensive `try` can still produce a plan that is mostly `(known after apply)`. ## What good looks like Narrow scope, the right function for the actual failure mode, and a preference for making bad input impossible over making it survivable. If a reviewer cannot tell from the expression *which* absence you were tolerating, the `try` is too wide.
- Why is try(jsondecode(file(var.path)).db.port, 5432) considered a bad use of try?Its scope is far too wide. A missing file, malformed JSON, a renamed key and a typo all collapse into the same silent fallback, so a broken input applies successfully against the wrong value. Keep the try around the smallest traversal that can legitimately be absent, and let genuine input errors fail loudly.
- How is coalesce different from a null check?`coalesce` skips arguments that are null *or* an empty string, returning the first that is neither. A `!= null` test accepts an empty string as a real value. That difference decides which you want: for user-supplied strings where blank means "not provided", coalesce is right; where an empty string is meaningful data, it is wrong.
- Does try() help when the value it wraps is unknown at plan time?No. Unknown is not an error — it is a value that will be known after apply — so try neither catches it nor resolves it; the unknown simply propagates through. That is why heavily defensive configurations can still produce a plan full of (known after apply): the defence is against structure, not against timing.
saying these in an interview costs you the question
- Calls try a general exception handler for any failure
- Wraps a whole expression in try and calls it defensive coding
- Thinks lookup without a default returns null for a missing key
- Assumes coalesce only skips null, not empty strings
- Uses try to work around an unknown value at plan time