skip to content

In Terraform, what class of errors does the try() function actually catch, and when is lookup() or coalesce() the better choice?

level: seniorimportance: should knowfreq 42%

answer

  1. only structural errors, not everything
  2. narrow the scope of the fallback
  3. missing key versus present-but-null
  4. boolean sibling returns true or false
  5. fix the type instead of the expression

basics

~20 s

try() 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 lines
hcl
locals {
  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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context