Terraform try vs can Functions

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.

text
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.

NOTE
Run terraform init in each new directory. For broader function coverage, see Terraform functions — this article focuses on when 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.

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/try-missing-attribute
cd ~/terraform-labs/terraform-try-can/demos/try-missing-attribute

Model inconsistent service objects — some include port, the web entry does not:

bash
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
}
EOF

Apply try() in locals and surface the resolved values in plan output:

bash
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
}
EOF

Initialize the demo directory:

bash
terraform init -input=false

Plan and read the resolved ports in the output block:

bash
terraform plan -no-color -input=false
output
+ 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.

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/can-check
cd ~/terraform-labs/terraform-try-can/demos/can-check

Compare try and can on objects with and without a port attribute:

bash
cat > variables.tf <<'EOF'
variable "with_port" {
  type = object({
    port = number
  })
  default = {
    port = 3000
  }
}

variable "without_port" {
  type = object({
    name = string
  })
  default = {
    name = "web"
  }
}
EOF

Write main.tf to compare try, can, and a conditional gate in one plan:

bash
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
  }
}
EOF

Initialize and plan:

bash
terraform init -input=false

Compare try fallbacks against can boolean results in the plan output:

bash
terraform plan -no-color -input=false
output
+ 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.

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/normalize-locals
cd ~/terraform-labs/terraform-try-can/demos/normalize-locals

Store the raw YAML in a string variable — web omits port, batch omits host, and neither sets tls:

bash
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
}
EOF

Normalize each app in locals before for_each:

bash
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
}
EOF

Initialize and plan:

bash
terraform init -input=false

Plan and confirm each app received the expected defaults for missing YAML fields:

bash
terraform plan -no-color -input=false
output
# 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.

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/normalize-json-input
cd ~/terraform-labs/terraform-try-can/demos/normalize-json-input

Write the JSON input file with rows missing host or port:

bash
cat > instances.json <<'EOF'
[
  {"id": "a1", "host": "10.0.0.1", "port": 80},
  {"id": "b2", "host": "10.0.0.2"},
  {"id": "c3", "port": 443}
]
EOF

Decode and normalize in locals:

bash
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
}
EOF

Initialize and plan:

bash
terraform init -input=false

Plan shows how try filled missing host or port on each JSON row:

bash
terraform plan -no-color -input=false
output
# 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:

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/can-validation
cd ~/terraform-labs/terraform-try-can/demos/can-validation

Write variables with can(regex(...)) and can(tonumber(...)) conditions:

bash
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."
  }
}
EOF

Add a minimal resource so plan has something to evaluate:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

resource "terraform_data" "validated" {
  input = {
    env  = var.env
    port = var.port
  }
}
EOF

Initialize the validation demo:

bash
terraform init -input=false

Defaults pass validate:

bash
terraform validate -no-color
output
Success! The configuration is valid.

Probe can(regex(...)) in console:

bash
printf '%s\n' 'can(regex("^prod$", "prod"))' | terraform console -no-color
output
true

An invalid environment fails at plan time:

bash
terraform plan -no-color -input=false -var='env=staging'
output
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:

bash
terraform plan -no-color -input=false -var='port=notnum'
output
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.

bash
mkdir -p ~/terraform-labs/terraform-try-can/errors/undeclared-reference
cd ~/terraform-labs/terraform-try-can/errors/undeclared-reference

Wrap a reference to a variable that does not exist:

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

locals {
  bad = try(var.missing.attr, "default")
}
EOF

Initialize and validate:

bash
terraform init -input=false

Validate still fails because var.missing was never declared:

bash
terraform validate -no-color
output
Error: 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:

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/dynamic-conversion
cd ~/terraform-labs/terraform-try-can/demos/dynamic-conversion

Define a string port that does not parse as a number:

bash
cat > variables.tf <<'EOF'
variable "port" {
  type    = string
  default = "not-a-number"
}
EOF

Read the port through try(tonumber(...), 8080):

bash
cat > main.tf <<'EOF'
terraform {
  required_version = ">= 1.12.0"
}

output "normalized_port" {
  value = try(tonumber(var.port), 8080)
}
EOF

Initialize and plan with the default invalid port string:

bash
terraform init -input=false

Plan shows the fallback value in the output:

bash
terraform plan -no-color -input=false
output
Changes 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:

text
declared value + conversion/access may fail at evaluation  →  try can handle it
undeclared/malformed reference                           →  try cannot handle it

try, 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.

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/try-vs-lookup
cd ~/terraform-labs/terraform-try-can/demos/try-vs-lookup

Write the map and loose object variables:

bash
cat > variables.tf <<'EOF'
variable "tags" {
  type = map(string)
  default = {
    env  = "prod"
    team = "platform"
  }
}

variable "server" {
  type    = any
  default = { name = "web-1" }
}
EOF

Compare both default patterns in one resource:

bash
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
  }
}
EOF

Initialize and plan:

bash
terraform init -input=false && terraform plan -no-color -input=false
output
+ 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.

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/try-vs-optional
cd ~/terraform-labs/terraform-try-can/demos/try-vs-optional

Define a typed object with optional() and a legacy JSON string:

bash
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\"}"
}
EOF

Read both port values in one summary resource:

bash
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
  }
}
EOF

Initialize and plan:

bash
terraform init -input=false && terraform plan -no-color -input=false
output
+ input = {
      + legacy_port = 8080
      + typed_port  = 8080
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Both paths default port to 8080optional() 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.

bash
mkdir -p ~/terraform-labs/terraform-try-can/demos/try-masks-schema-error
cd ~/terraform-labs/terraform-try-can/demos/try-masks-schema-error

Define a service object with a non-numeric port string:

bash
cat > variables.tf <<'EOF'
variable "service_raw" {
  type = object({
    name = string
    port = string
  })
  default = {
    name = "billing"
    port = "not-a-port"
  }
}
EOF

Mask the bad port with try(tonumber(...), 8080):

bash
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
}
EOF

Initialize the directory:

bash
terraform init -input=false

Plan with the bad port string and note the silent fallback to 8080:

bash
terraform plan -no-color -input=false
output
+ 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:

bash
printf '%s\n' 'tonumber(var.service_raw.port)' | terraform console -no-color
output
Error: 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


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.


Frequently Asked Questions

1. What is the difference between try and can in Terraform?

try evaluates expressions left to right and returns the first result that succeeds, which makes it useful for fallbacks when a missing attribute or failed conversion would otherwise error. can evaluates one expression and returns true or false depending on whether that expression succeeds, which suits variable validation conditions rather than supplying a default value elsewhere in configuration.

2. Can try catch undeclared variable errors?

No. try only handles errors that occur while evaluating its argument expressions. References to undeclared variables, resources, or outputs fail during static analysis before try runs, so validate and plan still error with undeclared reference messages.

3. When should I use try instead of lookup or optional?

Use lookup for optional keys on maps with a default third argument. Use optional inside object type constraints when you control the variable schema. Use try when reading decoded JSON or YAML where object shapes vary and attributes may be absent at evaluation time.

4. Should I wrap every expression in try?

No. Concentrate try in normalization locals that convert external data once, then reference the normalized structure elsewhere. Wrapping everything hides typos, masks invalid input, and makes refactors harder to detect.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)