skip to content

Why do Terraform validation conditions so often wrap an expression in `can()`, and what goes wrong if the condition expression itself raises an error?

level: middleimportance: should knowfreq 40%

answer

  1. a condition must be boolean
  2. some functions raise, not return false
  3. regex extracts, it does not test
  4. did that work? versus what works?
  5. never able to fail, only to be false

basics

~20 s

A condition must evaluate to true or false, but functions like regex(), cidrsubnet() and tonumber() raise an error on malformed input instead of returning false. can() runs the expression and yields false if it errored, so the caller sees your error_message rather than a raw function failure.

solid answer

~50 s

Several HCL functions signal "this input is not what I expected" by raising an error rather than returning `false` — `regex` when the pattern does not match, `cidrsubnet` and `cidrnetmask` on a malformed CIDR, `tonumber` on a non-numeric string. Put one of those directly in a `condition` and a bad value produces the function's own error, which fails the run but with a message about regular expressions instead of the readable message you wrote. `can()` wraps the expression, evaluates it, and returns `true` if it succeeded or `false` if it raised — which is exactly the boolean a condition wants, so control flows back to your `error_message`. `try()` is the related idiom for when you need a fallback *value* rather than a boolean: it returns the first of its arguments that evaluates without error. The rule of thumb is that a condition should never be able to fail; it should only be able to be false.

code

hcl · 17 lines
hcl
variable "bucket_name" {
  type = string

  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
    error_message = "The bucket_name must be 3-63 lowercase letters, digits, dots or hyphens, starting and ending with a letter or digit."
  }
}

variable "subnet_cidr" {
  type = string

  validation {
    condition     = can(cidrnetmask(var.subnet_cidr))
    error_message = "The subnet_cidr value must be a valid IPv4 CIDR block, for example 10.0.1.0/24."
  }
}

go deeper

for a junior

Remember the pairing: can(regex(...)) is the standard way to check that a string matches a pattern, because regex on its own errors instead of returning false. Recognise the idiom when you read it in a module.

for a middle

Explain the mechanic — a condition must yield a boolean, can converts a raised error into false — and name other raising functions such as cidrsubnet, cidrnetmask and tonumber. Distinguish can from try cleanly.

for a senior

Argue the point behind it: the run stopping is not the value, the readable explanation is, and an unwrapped raising function throws that value away. Flag over-broad can() wrappers in review as a way to hide your own bugs.

for a principal

Treat error-message quality as an interface concern. Across a module library, the difference between a raw function error and a written rule is measured in support requests, and it is worth making a review standard.

## The shape a condition must have The `condition` argument of a `validation` block — and of a `precondition` or `postcondition` — must produce a boolean. Terraform then takes one of two paths: `true` and it continues, `false` and it reports your `error_message`. There is a third outcome nobody designs for: the expression *errors*. Evaluation of the condition itself blows up. Terraform still fails the run, but the diagnostic the operator reads comes from the function that exploded, not from you. ## Which functions error instead of returning false This catches people because the failure mode is invisible in the happy path. Several HCL functions treat malformed input as an error, on the reasoning that in most contexts a silently wrong value would be worse: - `regex(pattern, string)` — raises an error when the pattern does not match. It is a *extract the match* function, not a *does it match* function. - `cidrsubnet`, `cidrhost`, `cidrnetmask` — raise on a string that is not a valid CIDR prefix. - `tonumber` — raises on a string that does not parse as a number. - Indexing a list past its end, or looking up a map key that is not there. Write `condition = regex("^[a-z-]+$", var.name) != ""` and the valid case works fine while the invalid case — the one the rule exists for — produces `Call to function "regex" failed: pattern did not match any part of the given string`. Technically the run stopped, so nothing was built. Practically you have replaced a message explaining the naming standard with an error about pattern matching, and the person reading it now has to open your module to find out what it wanted. ## What can() does `can(expression)` evaluates its argument and returns `true` if evaluation succeeded and `false` if it raised an error. That converts "this function refused to process the value" into "the value is not acceptable", which is precisely the boolean a condition needs. ```hcl variable "bucket_name" { type = string validation { condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name)) error_message = "The bucket_name must be 3-63 lowercase letters, digits, dots or hyphens, starting and ending with a letter or digit." } } ``` Now a malformed name gets that sentence. `can(cidrnetmask(var.vpc_cidr))` is the same trick for "is this a real CIDR block", and `can(tonumber(var.port))` for "is this string numeric". Two cautions. First, `can` is deliberately narrow: it is meant for wrapping one expression whose failure you understand, not for swallowing whatever a large expression might do. A `can()` around a compound expression turns *every* mistake in it, including your own typo in a variable name, into a plain `false` and therefore into a misleading error message. Second, it catches errors raised while evaluating the expression — it is not a general-purpose try/catch for the language, and it does not make an otherwise invalid configuration valid. ## try() and the difference `try(expr1, expr2, ...)` returns the first argument that evaluates without error. It is about producing a **value** with a fallback, not about producing a boolean: ```hcl locals { port = try(tonumber(var.port), 8080) } ``` In a validation context you occasionally want `try` to normalise before comparing, but the direct rule-shaped tool is `can`. A quick way to keep them straight: `can` asks *did that work?*, `try` asks *what is the first thing that works?* ## The principle underneath A condition should never be able to fail — only to be false. Whenever a condition calls a function that can raise, you either wrap it in `can()` or you have written a rule that reports someone else's error message on exactly the input it was built to reject. That is the whole idea, and it generalises: the value of validation is not that the run stops, it is that the run stops *with an explanation*, and an unwrapped error-raising function throws that value away.

  • What is the practical difference between `can()` and `try()`?
    `can()` returns a boolean — did that expression evaluate without error — so it belongs in a condition. `try()` returns a value — the first of its arguments that evaluates successfully — so it belongs where you need a fallback, such as `try(tonumber(var.port), 8080)` in a local. Conditions want `can`; defaulting expressions want `try`.
  • Why is wrapping a large compound expression in a single `can()` a bad idea?
    Because `can` cannot distinguish "the value was bad" from "my expression was wrong". A typo in a variable name, an out-of-range index, a reference to an attribute that does not exist — all collapse into `false`, and the caller gets your rule's error message for a defect in your own code. Wrap the one call that can legitimately raise.

saying these in an interview costs you the question

  • Assumes regex() returns false when the pattern does not match
  • Thinks can() is a general try/catch for the whole configuration
  • Uses try() where a boolean condition is required
  • Wraps an entire compound condition in one can() call
  • Says an erroring condition is fine because the run stops anyway

context