| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1 |
| Applies to | Any host with Terraform installed |
| Lab environment | Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu |
| Privilege | Normal user |
| Scope | Terraform try and can functions — missing attribute fallbacks on decoded objects, can in variable validation, normalization locals for YAML and JSON input, dynamic conversion fallbacks, errors try cannot catch, comparison with lookup and optional(), and common masking misuses. Does not replace the full Terraform functions catalog. |
| Related guides | Terraform functions Terraform expressions Validation and custom conditions Optional object attributes Unsupported attribute error fixes |
External data rarely arrives with a perfect schema. Decoded JSON might omit port on one service object; a YAML file might leave tls unset on another. try() gives you a fallback when an expression would fail at evaluation time. can() tells you whether an expression succeeds without returning the value itself.
try(expr, fallback) → first successful result (often a default)
can(expr) → true or false (primarily for validation)Neither function is a general exception handler. They handle dynamic evaluation failures such as missing object attributes — not undeclared references or every configuration mistake.
Each scenario uses its own directory under ~/terraform-labs/terraform-try-can/. Examples use terraform_data only.
try and can help versus when they hide real errors.
What try() does
try() evaluates its arguments left to right and returns the first result that does not error. When a decoded object omits an attribute, direct access such as s.port fails; try(s.port, 8080) returns the default instead.
mkdir -p ~/terraform-labs/terraform-try-can/demos/try-missing-attribute
cd ~/terraform-labs/terraform-try-can/demos/try-missing-attributeModel inconsistent service objects — some include port, the web entry does not:
cat > variables.tf <<'EOF'
variable "decoded" {
type = any
default = [
{ name = "api", port = 443 },
{ name = "web" },
{ name = "metrics", port = 9090 },
]
}
variable "services_json" {
type = string
default = <<-JSON
[
{"name":"api","port":443},
{"name":"web"},
{"name":"metrics","port":9090}
]
JSON
}
EOFApply try() in locals and surface the resolved values in plan output:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
ports_from_decoded = [for s in var.decoded : try(s.port, 8080)]
decoded_from_json = jsondecode(var.services_json)
ports_from_json = [for s in local.decoded_from_json : try(s.port, 8080)]
web_entry = [for s in var.decoded : s if s.name == "web"][0]
web_port = try(local.web_entry.port, 8080)
}
resource "terraform_data" "summary" {
input = {
ports_from_decoded = local.ports_from_decoded
ports_from_json = local.ports_from_json
web_port = local.web_port
}
}
output "web_port_via_try_on_missing_attribute" {
value = local.web_port
}
EOFInitialize the demo directory:
terraform init -input=falsePlan and read the resolved ports in the output block:
terraform plan -no-color -input=false+ input = {
+ ports_from_decoded = [443, 8080, 9090]
+ ports_from_json = [443, 8080, 9090]
+ web_port = 8080
}
Changes to Outputs:
+ web_port_via_try_on_missing_attribute = 8080
Plan: 1 to add, 0 to change, 0 to destroy.The web object had no port key, so try supplied 8080 while api and metrics kept their explicit values. The same pattern works on jsondecode(var.services_json) when the JSON string omits fields — both paths resolve to [443, 8080, 9090].
What can() does
can() evaluates a single expression and returns true when it succeeds or false when it would error. It does not return the expression value — only whether evaluation succeeded.
mkdir -p ~/terraform-labs/terraform-try-can/demos/can-check
cd ~/terraform-labs/terraform-try-can/demos/can-checkCompare try and can on objects with and without a port attribute:
cat > variables.tf <<'EOF'
variable "with_port" {
type = object({
port = number
})
default = {
port = 3000
}
}
variable "without_port" {
type = object({
name = string
})
default = {
name = "web"
}
}
EOFWrite main.tf to compare try, can, and a conditional gate in one plan:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
try_with_port = try(var.with_port.port, 8080)
try_without_port = try(var.without_port.port, 8080)
can_with_port = can(var.with_port.port)
can_without_port = can(var.without_port.port)
port_via_can_gate = can(var.without_port.port) ? var.without_port.port : 8080
}
resource "terraform_data" "summary" {
input = {
try_with_port = local.try_with_port
try_without_port = local.try_without_port
can_with_port = local.can_with_port
can_without_port = local.can_without_port
port_via_can_gate = local.port_via_can_gate
}
}
EOFInitialize and plan:
terraform init -input=falseCompare try fallbacks against can boolean results in the plan output:
terraform plan -no-color -input=false+ input = {
+ can_with_port = true
+ can_without_port = false
+ port_via_can_gate = 8080
+ try_with_port = 3000
+ try_without_port = 8080
}
Plan: 1 to add, 0 to change, 0 to destroy.When port exists, can(var.with_port.port) is true and try returns 3000. When port is absent, can is false and try falls back to 8080. The port_via_can_gate local shows that can() can drive a conditional, but for normal fallback logic prefer try(var.without_port.port, 8080) — it returns the value directly without a separate boolean branch.
try vs can
try() |
can() |
|
|---|---|---|
| Arguments | One or more expressions (left-to-right) | Single expression |
| Return value | First successful result | true or false |
| Typical use | Fallbacks and normalization | Variable validation / boolean error tests |
| Missing attribute | try(obj.field, default) → default |
can(obj.field) → false |
| Value access | Returns the value directly | Does not return the value |
Use can() primarily when an error needs to become a boolean result, especially inside variable validation blocks. For normal fallback and normalization logic elsewhere, prefer try() when you need the resulting value.
Use try for data normalization
HashiCorp recommends concentrating try in normalization locals rather than scattering it through resource blocks. Decode external data once, apply defaults, then pass the normalized structure downstream.
mkdir -p ~/terraform-labs/terraform-try-can/demos/normalize-locals
cd ~/terraform-labs/terraform-try-can/demos/normalize-localsStore the raw YAML in a string variable — web omits port, batch omits host, and neither sets tls:
cat > variables.tf <<'EOF'
variable "config_yaml" {
type = string
default = <<-YAML
apps:
web:
host: web.local
batch:
port: 9000
api:
host: api.local
port: 443
tls: true
YAML
}
EOFNormalize each app in locals before for_each:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
decoded = yamldecode(var.config_yaml)
normalized_apps = {
for name, app in local.decoded.apps : name => {
host = try(app.host, "127.0.0.1")
port = try(app.port, 8080)
tls = try(app.tls, false)
}
}
}
resource "terraform_data" "apps" {
for_each = local.normalized_apps
input = each.value
}
EOFInitialize and plan:
terraform init -input=falsePlan and confirm each app received the expected defaults for missing YAML fields:
terraform plan -no-color -input=false# terraform_data.apps["web"] will be created
+ input = {
+ host = "web.local"
+ port = 8080
+ tls = false
}
# terraform_data.apps["batch"] will be created
+ input = {
+ host = "127.0.0.1"
+ port = 9000
+ tls = false
}
Plan: 3 to add, 0 to change, 0 to destroy.web picked up default port 8080 and tls = false; batch picked up default host 127.0.0.1. Downstream resources read local.normalized_apps without repeating try on every field.
Normalize JSON from a file
The same normalization pattern works on a JSON file beside the configuration.
mkdir -p ~/terraform-labs/terraform-try-can/demos/normalize-json-input
cd ~/terraform-labs/terraform-try-can/demos/normalize-json-inputWrite the JSON input file with rows missing host or port:
cat > instances.json <<'EOF'
[
{"id": "a1", "host": "10.0.0.1", "port": 80},
{"id": "b2", "host": "10.0.0.2"},
{"id": "c3", "port": 443}
]
EOFDecode and normalize in locals:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
raw_instances = jsondecode(file("${path.module}/instances.json"))
normalized = {
for inst in local.raw_instances : inst.id => {
host = try(inst.host, "0.0.0.0")
port = try(inst.port, 8080)
}
}
}
resource "terraform_data" "instance" {
for_each = local.normalized
input = each.value
}
EOFInitialize and plan:
terraform init -input=falsePlan shows how try filled missing host or port on each JSON row:
terraform plan -no-color -input=false# terraform_data.instance["b2"] will be created
+ input = {
+ host = "10.0.0.2"
+ port = 8080
}
# terraform_data.instance["c3"] will be created
+ input = {
+ host = "0.0.0.0"
+ port = 443
}
Plan: 3 to add, 0 to change, 0 to destroy.Instance b2 lacked port; instance c3 lacked host. Normalization happened once in locals before resources consumed the map.
Use can for validation
Variable validation blocks need a boolean condition. That is the primary intended use of can() — express “this expression must succeed” without returning the parsed value to the rest of the configuration.
Create the validation demo directory:
mkdir -p ~/terraform-labs/terraform-try-can/demos/can-validation
cd ~/terraform-labs/terraform-try-can/demos/can-validationWrite variables with can(regex(...)) and can(tonumber(...)) conditions:
cat > variables.tf <<'EOF'
variable "env" {
type = string
default = "prod"
validation {
condition = can(regex("^prod$", var.env)) || can(regex("^dev$", var.env))
error_message = "env must be prod or dev (regex validation via can())."
}
}
variable "port" {
type = string
default = "8080"
validation {
condition = can(tonumber(var.port))
error_message = "port must be a numeric string."
}
}
EOFAdd a minimal resource so plan has something to evaluate:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
resource "terraform_data" "validated" {
input = {
env = var.env
port = var.port
}
}
EOFInitialize the validation demo:
terraform init -input=falseDefaults pass validate:
terraform validate -no-colorSuccess! The configuration is valid.Probe can(regex(...)) in console:
printf '%s\n' 'can(regex("^prod$", "prod"))' | terraform console -no-colortrueAn invalid environment fails at plan time:
terraform plan -no-color -input=false -var='env=staging'Error: Invalid value for variable
on variables.tf line 1:
1: variable "env" {
├────────────────
│ var.env is "staging"
env must be prod or dev (regex validation via can()).Similarly, a non-numeric port string triggers the can(tonumber(...)) rule:
terraform plan -no-color -input=false -var='port=notnum'Error: Invalid value for variable
on variables.tf line 11:
11: variable "port" {
├────────────────
│ var.port is "notnum"
port must be a numeric string.Errors try cannot catch
try is not a try/catch block for all Terraform errors. It only handles failures during evaluation of its argument expressions.
Undeclared references
Undeclared names fail during static analysis before try runs.
mkdir -p ~/terraform-labs/terraform-try-can/errors/undeclared-reference
cd ~/terraform-labs/terraform-try-can/errors/undeclared-referenceWrap a reference to a variable that does not exist:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
bad = try(var.missing.attr, "default")
}
EOFInitialize and validate:
terraform init -input=falseValidate still fails because var.missing was never declared:
terraform validate -no-colorError: Reference to undeclared input variable
on main.tf line 6, in locals:
6: bad = try(var.missing.attr, "default")
An input variable with the name "missing" has not been declared. This
variable can be declared with a variable "missing" {} block.Terraform rejects the reference before try can evaluate. The fallback never runs. Fix the variable declaration or correct the attribute path — do not expect try to rescue typos in names.
Dynamic conversion failures try can handle
Declared values with conversions or attribute access that fail at evaluation time are fair game for try().
Create the conversion demo directory:
mkdir -p ~/terraform-labs/terraform-try-can/demos/dynamic-conversion
cd ~/terraform-labs/terraform-try-can/demos/dynamic-conversionDefine a string port that does not parse as a number:
cat > variables.tf <<'EOF'
variable "port" {
type = string
default = "not-a-number"
}
EOFRead the port through try(tonumber(...), 8080):
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
output "normalized_port" {
value = try(tonumber(var.port), 8080)
}
EOFInitialize and plan with the default invalid port string:
terraform init -input=falsePlan shows the fallback value in the output:
terraform plan -no-color -input=falseChanges to Outputs:
+ normalized_port = 8080
You can apply this plan to save these new output values to the Terraform
state, without changing any real infrastructure.tonumber(var.port) fails on "not-a-number", so try returns 8080. That is the intended pattern for caller-supplied strings that may not parse — but pair it with can(tonumber(...)) validation when bad input should stop the run instead of silently falling back.
The mental model:
declared value + conversion/access may fail at evaluation → try can handle it
undeclared/malformed reference → try cannot handle ittry, lookup, and optional()
Three tools address “value might be missing,” at different layers:
| Tool | Best for | Example |
|---|---|---|
lookup(map, key, default) |
Optional keys on a map | lookup(var.tags, "zone", "z1") |
optional(type, default) |
Module variables with a fixed object schema | port = optional(number, 8080) in type = object({...}) |
try(expr, default) |
Loose decoded data (JSON/YAML/any) |
try(app.port, 8080) after jsondecode |
lookup on maps versus try on loose objects
lookup fits map keys; try fits missing attributes on decoded or any objects.
mkdir -p ~/terraform-labs/terraform-try-can/demos/try-vs-lookup
cd ~/terraform-labs/terraform-try-can/demos/try-vs-lookupWrite the map and loose object variables:
cat > variables.tf <<'EOF'
variable "tags" {
type = map(string)
default = {
env = "prod"
team = "platform"
}
}
variable "server" {
type = any
default = { name = "web-1" }
}
EOFCompare both default patterns in one resource:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
zone_from_lookup = lookup(var.tags, "zone", "z1")
zone_from_try = try(var.server.zone, "z1")
}
resource "terraform_data" "summary" {
input = {
zone_from_lookup = local.zone_from_lookup
zone_from_try = local.zone_from_try
}
}
EOFInitialize and plan:
terraform init -input=false && terraform plan -no-color -input=false+ input = {
+ zone_from_lookup = "z1"
+ zone_from_try = "z1"
}
Plan: 1 to add, 0 to change, 0 to destroy.lookup supplies the default when the map key is absent. try supplies the default when the loose object has no zone attribute.
optional() on typed input versus try on legacy JSON
Typed module input and legacy JSON need different tools at the boundary.
mkdir -p ~/terraform-labs/terraform-try-can/demos/try-vs-optional
cd ~/terraform-labs/terraform-try-can/demos/try-vs-optionalDefine a typed object with optional() and a legacy JSON string:
cat > variables.tf <<'EOF'
variable "typed_config" {
type = object({
name = string
port = optional(number, 8080)
tls = optional(bool, false)
})
default = { name = "web" }
}
variable "legacy_json" {
type = string
default = "{\"name\":\"legacy\"}"
}
EOFRead both port values in one summary resource:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
legacy_decoded = jsondecode(var.legacy_json)
legacy_port = try(local.legacy_decoded.port, 8080)
}
resource "terraform_data" "summary" {
input = {
typed_port = var.typed_config.port
legacy_port = local.legacy_port
}
}
EOFInitialize and plan:
terraform init -input=false && terraform plan -no-color -input=false+ input = {
+ legacy_port = 8080
+ typed_port = 8080
}
Plan: 1 to add, 0 to change, 0 to destroy.Both paths default port to 8080 — optional() at assignment time for typed input, try() at read time for loose JSON. When you own the module interface, prefer optional() in the type constraint; reserve try for data you do not control. See Optional object attributes for the full optional() pattern.
Common misuses
Wrapping everything in try
Sprinkling try on every attribute access hides typos and makes refactors painful. Normalize once in locals, then reference clean field names in resources.
Masking schema and data quality errors
try(tonumber(...), default) can hide invalid caller strings unless you validate upstream.
mkdir -p ~/terraform-labs/terraform-try-can/demos/try-masks-schema-error
cd ~/terraform-labs/terraform-try-can/demos/try-masks-schema-errorDefine a service object with a non-numeric port string:
cat > variables.tf <<'EOF'
variable "service_raw" {
type = object({
name = string
port = string
})
default = {
name = "billing"
port = "not-a-port"
}
}
EOFMask the bad port with try(tonumber(...), 8080):
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
}
locals {
port_masked = try(tonumber(var.service_raw.port), 8080)
}
resource "terraform_data" "summary" {
input = {
name = var.service_raw.name
port_masked = local.port_masked
}
}
output "port_masked_by_try" {
value = local.port_masked
}
EOFInitialize the directory:
terraform init -input=falsePlan with the bad port string and note the silent fallback to 8080:
terraform plan -no-color -input=false+ input = {
+ name = "billing"
+ port_masked = 8080
}
Changes to Outputs:
+ port_masked_by_try = 8080
Plan: 1 to add, 0 to change, 0 to destroy.The invalid string silently became 8080. Without try, the same value errors in console:
printf '%s\n' 'tonumber(var.service_raw.port)' | terraform console -no-colorError: Invalid function argument
Invalid value for "v" parameter: cannot convert "not-a-port" to number; given
string must be a decimal representation of a number.Use can(tonumber(...)) in validation when bad input should fail the run. Use try(tonumber(...), default) only when a fallback is genuinely acceptable.
Using try instead of proper object types
When module callers pass structured objects you define, model optional fields with optional() in the type constraint instead of try on every access. When the problem is a wrong attribute name, fix the name — see Unsupported attribute error fixes rather than wrapping the expression in try.
| Symptom | Likely cause | Fix |
|---|---|---|
Undeclared reference inside try |
Static analysis runs first | Declare the variable or fix the reference path |
| Invalid input silently becomes default | try(tonumber(x), 8080) on bad strings |
Add validation { condition = can(tonumber(...)) } |
| Same default repeated in many resources | try scattered in resource blocks |
Move normalization into one locals block |
| Optional map key | Using try on map indexing |
Prefer lookup(map, key, default) |
| Optional field on typed module input | try on known schema |
Use optional() in the object type |
References
- try function — HashiCorp Terraform language docs
- can function — boolean evaluation guard
- Variable validation —
conditionanderror_messageblocks
Summary
try() and can() both interact with expressions that might fail, but they answer different questions. try returns the first successful value — ideal for normalization locals that convert inconsistent JSON or YAML into a stable shape with defaults. can returns whether an expression succeeds — ideal for variable validation conditions where a failed parse or pattern match should reject input.
The limit people hit most often is expecting try to catch everything. Undeclared variables and outputs fail before try runs. Dynamic failures such as missing object attributes or failed conversions on declared values are fair game — try(tonumber(var.port), 8080) is the canonical example. For module inputs you control, optional() and explicit object types beat scattered try calls; for external maps, lookup is clearer than try on bracket indexing.
Concentrate try at the boundary where external data enters your configuration. Validate caller input with can in validation blocks when bad values should stop the plan. Reserve try(tonumber(...), default) for cases where a silent fallback is a deliberate product choice, not a substitute for schema design.

