try() evaluates its arguments in order and returns the first one that does not error, which makes it the tool for reading values that may or may not exist in a variable.
It is deliberately narrow. try only catches errors that depend on data unknown until evaluation time — a missing map key, an index past the end of a list. A typo in a resource name is invalid before evaluation begins, and no amount of try will make it go away.
Table of contents
- The basic shape
- try versus can
- What try does not catch
- The optional() alternative
- The overuse problem
- Where this fits
- How this fits the rest of the stack
- FAQ
The basic shape
locals {
# returns the configured value, or "t3.micro" if the key is absent
instance_type = try(var.settings.instance_type, "t3.micro")
}
Arguments are evaluated left to right. The first that produces a result without erroring is returned. If all of them error, try re-raises the last error.
It chains as far as you need, which is the usual pattern for layered configuration:
locals {
region = try(
var.override.region, # explicit override
var.defaults.region, # environment default
"eu-west-1", # last resort
)
}
The value that most often justifies try is deep access into an object variable, where any level might be absent:
locals {
log_retention = try(var.config.observability.logs.retention_days, 30)
}
Without try, a missing observability key fails the plan. With it, you get 30.
try versus can
can evaluates an expression and returns a boolean — true if it succeeded, false if it errored. It never returns the value.
can(var.settings.instance_type) # true or false
try(var.settings.instance_type, "x") # the value, or the fallback
The division is clean once you see it. try is for producing a value. can is for producing a condition, and it exists almost entirely for variable validation blocks:
variable "bucket_name" {
type = string
validation {
condition = can(regex("^[a-z0-9-]+$", var.bucket_name))
error_message = "Bucket name must contain only lowercase letters, numbers, and hyphens."
}
}
regex errors when the pattern does not match, so can turns that error into the boolean a validation block needs. Using try here would mean writing try(regex(...), "") != "", which is the same idea wearing a worse outfit.
The rule from HashiCorp’s own documentation is worth quoting plainly: prefer try everywhere except inside validation conditions.
What try does not catch
This is where people get surprised. try only handles errors arising from values not known until evaluation. Anything statically invalid fails before try is involved.
# still an error -- the resource does not exist in the configuration
try(aws_instance.does_not_exist.id, "fallback")
# still an error -- malformed reference
try(var..broken, "fallback")
# still an error -- wrong argument count is a syntax problem
try(cidrsubnet("10.0.0.0/16"), "fallback")
What it does catch:
- A key missing from a map or object —
try(local.settings["absent"], null) - An index past the end of a list —
try(var.subnets[3], var.subnets[0]) - A type conversion that fails at runtime —
try(tonumber(var.text), 0) - An attribute missing from an object whose type came from a variable
The distinction is between “this reference is wrong” and “this data might not contain that”. try handles the second and deliberately refuses the first, which is correct — silently swallowing typos would make configuration errors invisible.
The optional() alternative
For object-typed variables, there is usually a better answer than wrapping every read in try: declare the attribute optional with a default in the type constraint.
variable "service" {
type = object({
name = string
instance_type = optional(string, "t3.micro")
replicas = optional(number, 2)
monitoring = optional(bool, false)
})
}
Now var.service.instance_type is always present, filled with the default when the caller omits it. No try needed anywhere downstream.
This is better for three reasons. The default lives in the variable declaration where someone reading the interface will find it, rather than scattered across every use site. Terraform validates the type properly. And you cannot accidentally use different defaults in two places, which is a genuine failure mode with try.
Reach for try when the data shape is genuinely outside your control — a remote state output from a module you do not own, a JSON file whose structure varies, an API response. Reach for optional() when you own the variable.
The overuse problem
try is easy to apply and easy to over-apply, and the failure mode is quiet.
# what could possibly go wrong
subnet_id = try(var.network.subnet_id, try(data.aws_subnet.default.id, ""))
That expression cannot fail. It can absolutely produce an empty string, which then gets passed to a resource that requires a subnet, producing an error hundreds of lines away from the cause — or worse, succeeding against something unintended.
Two rules keep it honest.
Never fall back to empty. try(x, "") and try(x, null) convert a clear failure into an unclear one. If a value is genuinely required, let it fail at the point where it is missing.
Do not wrap something that should exist. If var.network.subnet_id is required for the configuration to make sense, a missing value is a real error and the plan should say so. Wrapping it means someone discovers the problem after apply.
# better: fail early with a message that says what is wrong
variable "network" {
type = object({
subnet_id = string # required, no optional()
})
}
The instinct to make the plan stop erroring is understandable and usually backwards. A plan that fails is telling you something before it costs money. The goal is not silence — it is failing in the place where the message is useful.
Where this fits
Every function in this article exists because Terraform configurations grow interfaces, and interfaces grow optional fields. That is a real problem worth solving well, and try plus optional() solves it.
It is also worth occasionally asking how much of it you need. A module tree deep enough to require try at four levels of object access is a module tree that is difficult to reason about, and the fallback chains are load-bearing configuration that nobody has tested. Flattening the variable structure is often the better fix.
For a straightforward application — a service, a database, a static site, a domain — the infrastructure-as-code layer can be more overhead than the thing it manages. Deploying that on RunxBuild is connecting a repository and setting environment variables, with the runtime, TLS, and routing handled by the platform. Keep Terraform for the infrastructure that genuinely needs to be described in code, and skip the module tree for the parts that do not.
How this fits the rest of the stack
Configuration complexity is a cost that does not appear on an invoice, but the resources it describes do. The RunxBuild hosting calculator puts compute, database, storage, and bandwidth on one page so you can compare the whole shape against what you are running now.
Useful related references:
- Terraform Replace: lifecycle.replace_triggered_by and -replace
- Terraform Helm Provider: Managing Charts as Infrastructure
- Terraform Locals: Reuse Expressions Without Hiding Configuration
- Services on RunxBuild
FAQ
What does the try function do in Terraform?
It evaluates its arguments left to right and returns the first one that does not produce an error, which makes it the way to read values that may be absent. If every argument errors, try re-raises the last error.
What is the difference between try and can in Terraform?
try returns a value or a fallback; can returns only a boolean indicating whether an expression succeeded. Use can inside variable validation conditions, where you need a condition rather than a value, and try everywhere else.
Why does try not catch my error?
try only handles errors from data not known until evaluation, such as a missing map key or an out-of-range index. Statically invalid things — a reference to a resource that does not exist, a malformed reference, a wrong argument count — fail before try is ever evaluated.
Should I use try or optional for variable defaults?
optional() in the type constraint when you own the variable, because the default lives in the interface where readers find it and Terraform validates the type properly. try when the data shape is outside your control, such as a remote state output or a variable-structure JSON file.
Is it bad to wrap everything in try?
Yes. Falling back to an empty string or null converts a clear failure into an unclear one that surfaces far from its cause, and wrapping a genuinely required value means the problem is discovered after apply rather than during plan. Let required values fail where they are missing.