Use try to handle missing attributes and set defaults in Terraform.
Terraform's try function evaluates a series of expressions and returns the first one that does not produce an error and it's useful when a module accepts data in more than one shape, but broad use can hide invalid input and make a configuration harder to understand.
This guide explains how try behaves with optional attributes, decoded YAML and JSON, and values that are not known until Terraform runs. It also covers the cases where can, lookup, coalesce, or a stricter variable type makes the intended behavior clearer.
We'll also look at when the configuration layer behind these expressions can be avoided, and how Encore lets you declare application resources alongside the code instead of translating a separate YAML or JSON definition into Terraform.
A decoded YAML file may omit attributes that other environments provide. Some environments may set an optional replicas field while others rely on a default:
name: orders
port: 8080
replicas: 3
locals {
raw_service = yamldecode(file("${path.module}/service.yaml"))
service = {
name = tostring(local.raw_service.name)
port = tonumber(local.raw_service.port)
replicas = tonumber(try(local.raw_service.replicas, 1))
}
}
When replicas exists, the first expression succeeds. When the attribute is absent, Terraform evaluates the second argument and returns 1. The explicit conversions make the normalized object's types clear, and any code after this block can use local.service.replicas without repeating the fallback.
try does not treat null as an error. If the YAML explicitly contains replicas: null, the first expression succeeds and passes null to tonumber; depending on the intended contract, that may still produce a null result rather than the default. Use a nullable typed variable, coalesce, or an explicit condition when absence and null need the same behavior.
A module sometimes has to preserve an older interface that accepted one string while a newer caller passes a list. try can normalize both forms before the value reaches a resource:
variable "allowed_cidrs" {
type = any
nullable = false
validation {
condition = can(cidrhost(var.allowed_cidrs, 0)) || try(
alltrue([
for cidr in tolist(var.allowed_cidrs) : can(cidrhost(cidr, 0))
]),
false,
)
error_message = "allowed_cidrs must be a CIDR string or a list of non-null CIDR strings."
}
}
locals {
allowed_cidrs = try(
[tostring(var.allowed_cidrs)],
tolist(var.allowed_cidrs),
)
}
The validation accepts a CIDR string or a list of CIDR strings, rejecting null, numbers, booleans, maps, and null list elements. A valid string becomes a one-item list; a valid list fails the string conversion and reaches tolist. Validation establishes the accepted inputs, while try normalizes their shape.
Accepting both shapes can help during a controlled migration, but a permanently loose type = any interface gives callers less help than a typed variable. Once the old form has been removed, changing the variable to list(string) and deleting the normalization restores earlier validation and clearer editor feedback.
try handles errors that arise while Terraform evaluates data whose shape is not yet known. It cannot make a statically invalid reference valid:
locals {
value = try(local.does_not_exist, "fallback")
}
This ends up getting rejected by Terraform given local.does_not_exist was never declared. The reference is invalid for every possible input, so there is no runtime condition in which the first expression could succeed.
A similar distinction is with why try is not a substitute for input validation. A missing optional field in decoded YAML is a dynamic condition; a misspelled resource address, malformed reference, or incompatible expression should stop the configuration. Adding fallbacks to every attribute makes those two categories look the same and leaves maintainers guessing whether the default was intentional.
These functions overlap at the edges, but each communicates a different intent:
For example, a variable validation can use can to check whether a string is a parseable timestamp without discarding the original value:
variable "maintenance_at" {
type = string
validation {
condition = can(formatdate("", var.maintenance_at))
error_message = "maintenance_at must be a valid RFC 3339 timestamp"
}
}
For a simple map, lookup(var.tags, "team", "platform") says more directly what is happening than wrapping an index expression in try. Choosing the narrowest function leaves fewer errors eligible for suppression.
The safest arrangement is one local value that validates and normalizes external data, followed by ordinary typed references everywhere else. Give defaults names that reflect their meaning, fail when a required attribute is absent, and avoid using try(..., null) solely to make a broken expression proceed.
Terraform variable types, optional object attributes, validation blocks, preconditions, and postconditions can often express the contract before try is needed. A fallback is appropriate when more than one input shape is genuinely supported, not when the configuration has no clear decision about which shape callers should use.
Tests should cover every supported branch of the normalization. A small terraform test suite can load an object with the optional field, one without it, and one with an invalid type, ensuring the intentional fallback works without accepting malformed data.
Terraform still needs normalization when it consumes external configuration or manages resources with several valid input shapes. Some try blocks, however, exist only because an application declares its needs in one format and a separate infrastructure project translates those values into another.
Encore lets an application declare supported infrastructure resources in the code that uses them. Databases, queues, buckets, and scheduled jobs are then available in local development and provisioned for each deployed environment from the same declaration, without a YAML-to-HCL normalization layer between the feature and its infrastructure.
Terraform can remain in place for shared networking, IAM, accounts, and resources outside Encore's model, while application resources no longer need a parallel configuration shape that has to be decoded, defaulted, and kept in sync. Encore removes that source of try calls without pretending to replace every use of the function.
Terraform's try function handles dynamic errors such as missing attributes in decoded JSON or YAML by evaluating its arguments from left to right and returning the first one that succeeds.
No. It catches dynamic evaluation errors, but not references or expressions Terraform can prove are invalid before evaluation, such as an undeclared local value.
try returns the first successful value, while can returns true or false depending on whether one expression succeeds. can is particularly useful in custom variable validation.
lookup reads a key from a map and can return a default when the key is absent. try works with a wider range of dynamic errors, including optional object attributes and type conversions.
Usually not. HashiCorp recommends confining try to normalization locals so the rest of the module can work with one predictable data shape and still surface unexpected errors.
Yes. Encore lets you declare resources such as databases, queues, and buckets in application code. Those declarations are used for local development and cloud provisioning, removing the need to decode and normalize a separate infrastructure definition for those resources.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.