Oct 12, 20265 min read

How to use try in Terraform

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.

Return a fallback for an optional attribute

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.

Normalize more than one accepted input shape

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.

Know which errors try cannot catch

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.

Choose try, can, lookup, or coalesce

These functions overlap at the edges, but each communicates a different intent:

FunctionUse it when
try(a, b)Evaluating a may produce a dynamic error, and b is the intentional fallback.
can(a)A validation rule needs a boolean that reports whether a can be evaluated.
lookup(map, key, default)The value is a map and an absent key should return a default.
coalesce(a, b)The expressions are valid, but the first null or empty-string value should be skipped.

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.

Keep fallbacks at the boundary

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.

Removing the application configuration bridge

Encore

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.

Learn how Encore works →

Frequently asked questions

What does try do in Terraform?

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.

Can Terraform try catch every error?

No. It catches dynamic evaluation errors, but not references or expressions Terraform can prove are invalid before evaluation, such as an undeclared local value.

What is the difference between try and can in Terraform?

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.

What is the difference between try and lookup?

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.

Should Terraform try be used throughout a module?

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.

Can I define application infrastructure without separate YAML or JSON configuration?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders