Rejecting "Production" When You Meant "prod", Before Anything Is Created
A teammate sets environment = "Production" in a tfvars file. Nothing in a bare declaration stops it: the type is string, the value is a string, so plan proceeds and you get a second stack of resources with environment = "Production" tags, a differently named S3 prefix, and a diff that takes an hour to untangle. The fix is to attach the business rule to the variable itself, where every future caller meets it:
variable "environment" {
type = string
description = "Deployment environment."
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}
variable "instance_count" {
type = number
default = 2
validation {
condition = var.instance_count >= 1 && var.instance_count <= 20
error_message = "instance_count must be between 1 and 20."
}
}
The anatomy is fixed. condition must evaluate to a boolean — not a value, not a null. error_message is required, and since Terraform 0.14 it must be a literal string without ${...} interpolation of dynamic values, so describe the rule rather than trying to echo the bad input. A variable can carry multiple validation blocks; all of them run, and the first failure aborts the run with its message.
Where does it fire? During terraform plan and terraform apply, and it is also what terraform validate exercises without credentials. The timing is the point: the error appears before any provider is configured or API called, so a bad value costs you ten seconds instead of a partial deployment. That property makes validation blocks runnable in a cheap pre-merge job with no cloud access at all:
terraform init -backend=false
terraform validate # every validation rule in the repository, zero API calls
Two practical details separate a working rule from a decorative one. First, condition must be a genuine boolean: a numeric truthy value, an unset optional that evaluates to null, or a string are all errors at evaluation time — coerce explicitly with coalesce(var.x, "") or a ternary when the input may be null. Second, the rule runs on every plan against whatever source supplied the value, so an over-strict regex that rejects a legitimate legacy value will block unrelated changes; loosen the pattern to what you actually enforce, not to what the example in the docs shows.
Formats are where functions earn their place. contains covers allow-lists; regular expressions cover shapes:
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,30}$", var.name_prefix))
error_message = "name_prefix must be 3-30 chars: lowercase letters, digits, hyphens, starting with a letter."
}
can(...) converts an error into false: regex throws when the pattern does not match, can catches that and returns false, and the condition fails cleanly instead of crashing evaluation. The same pattern works with cidrhost(var.vpc_cidr, 0) to prove a CIDR parses, and with contains(["gp3", "io1"], var.volume_type) for enums. Boolean operators combine rules — var.min <= var.max, length(var.azs) >= 2 — and a ternary expresses "required when enabled": condition = var.enabled == false || length(var.subnet_ids) > 0.
Interviewers like this topic because it exposes whether you have shipped configuration that other people run. The stock question is "how do you make a module hard to misuse?" — the answer is a type constraint first (from Input Variable Declarations), then a validation block with a message a caller can act on without reading your source. What validation cannot do — check the value against another variable, a data source, or real infrastructure — is the subject of the next chapter, and it has a sharp version boundary.
Checking One Variable Against Another: the Terraform 1.9 Boundary
The rule "cluster endpoint must be set when we are not creating a cluster" spans two variables, and for years that was impossible to express where it belonged. Before Terraform 1.9, a validation condition could reference only the variable being validated. Reaching for anything else produced a hard failure at plan time:
Error: Invalid reference in variable validation
on variables.tf line 10:
10: condition = var.create_cluster == false || length(var.endpoint) > 0
Variables in variable validation may not refer to other variables.
Terraform 1.9 lifted the restriction: validation conditions became general expressions that may refer to other input variables, locals, and even data resources declared in the same module. The same rule now reads naturally:
variable "create_cluster" {
type = bool
default = true
}
variable "cluster_endpoint" {
type = string
default = ""
validation {
condition = var.create_cluster || length(var.cluster_endpoint) > 0
error_message = "cluster_endpoint is required when create_cluster is false."
}
}
That version boundary is worth memorizing, because it decides your options on a pinned codebase:
| Terraform version | What a validation condition may reference |
|---|---|
| 0.13 – 1.8 | Only the variable being validated |
| 1.9+ | Other variables, locals, data sources, any object in the same module |
If you are stuck below 1.9, the workaround is to move the rule to a precondition on a resource or data source (next chapter), or on a null_resource if no natural resource exists. The trade-off is real: preconditions evaluate as part of the resource graph, so the failure surfaces later than a variable validation and — if another resource in the graph was already able to proceed — potentially alongside partial progress. Upgrading is the better answer when you control the toolchain; the workaround is for estates pinned by an old CI image. The upgrade also changes where the check catches you: on 1.9+ a cross-variable rule fires during terraform validate, which CI can run with no credentials at all, while below 1.9 the same rule only surfaces when the graph is evaluated during plan.
There is a second design question even when the check runs early: where should the rule live? Put it in the module that owns the consequence. If your network module assumes at least two AZs, its az_count variable validates that constraint; a caller should never need to know the internal assumption. Do not centralize every rule in the root — a validation block in a child module travels with the module, executes no matter who instantiates it, and shows up in terraform registry documentation automatically.
And since validation is pure evaluation, it is cheap to test declaratively. terraform test gained the ability to assert that a bad input is rejected, which turns the rules into a regression suite:
run "rejects_bad_environment" {
command = plan
variables = { environment = "Production" }
expect_failures = [var.environment]
}
expect_failures takes the address of the check subject — var.environment for a validation rule, a resource address for a precondition — and a run that fails anywhere else still fails the test. When a teammate tightens a rule in a shared module, CI says so instead of the next deploy discovering it.
Write error messages for the person who will hit them at 2 a.m.: name the variable, state the allowed set, and when relevant say where the value came from (environment is set from *.auto.tfvars — expected one of dev, staging, prod). Keep the rule in the same file as the variable it guards; a validation block three files away is a rule nobody reads during review. A validation rule with a vague message is a failed assertion; a validation rule with a directive is documentation that runs.
precondition and postcondition: Guarding Stateful Things at Plan and Apply
Variable validation answers "is this input legal?" Two related blocks answer the harder questions: before I touch real infrastructure and after the dust settles. Since Terraform 1.2, precondition and postcondition blocks live inside lifecycle on resources and data sources, and inside output blocks.
resource "aws_instance" "web" {
count = var.instance_count
ami = var.ami_id
instance_type = var.instance_type
lifecycle {
precondition {
condition = var.environment != "prod" || var.instance_count >= 3
error_message = "Production requires at least 3 web instances (HA)."
}
postcondition {
condition = self.public_ip != ""
error_message = "Instance was created without a public IP; check the subnet's auto-assign setting."
}
}
}
The differences from variable validation are the evaluation point and the visibility:
| Check | Runs | Can see |
|---|---|---|
validation on a variable |
While resolving inputs, before planning resources | The variable (and from 1.9, anything in the module) |
lifecycle.precondition |
During plan for known values; again during apply before the resource operation | Any expression — variables, locals, other resources, data sources |
lifecycle.postcondition |
After the resource operation completes, during apply | self.<attribute> of the just-managed resource |
output precondition |
When evaluating the output | Anything the output expression can reach |
A precondition is the right place for cross-cutting facts that only exist at plan time: an AMI that must exist in the region, a CIDR that must not overlap an existing peering range, a production instance count that must satisfy a durability policy. Because it evaluates during plan when its inputs are known, it stops the run before the resource acts — and during apply it re-evaluates, so a value that changed between plan and apply still fails closed.
A postcondition asserts a result using self: the instance got an expected subnet, the bucket comes back with versioning enabled, the DNS record resolved to the load balancer's hostname. Note that self is how you reference the resource's own attributes inside lifecycle; other expressions are still referenced normally. Postconditions run after apply of that resource, so a failure means the object exists but violated its contract — read the message as a drift or provider-behavior alarm, not a rollback (Terraform does not undo the resource for you).
Preconditions on output deserve one more mention: they gate whether an output can be read at all, which is a clean way to stop a module from exporting a null endpoint when a conditional sub-module was disabled.
output "kms_key_arn" {
value = var.create_key ? aws_kms_key.state[0].arn : var.existing_key_arn
precondition {
condition = var.create_key || length(var.existing_key_arn) > 0
error_message = "Supply existing_key_arn when create_key is false."
}
}
Here the check belongs on the output rather than on a variable, because the bad state is not one input — it is the combination the output would have exported. A caller reading module.foundation.kms_key_arn gets a plan-time failure pointing at this block instead of an empty string that propagates into a backend configuration three modules away.
The practical hierarchy, and a good interview summary: type catches shape, validation catches meaning of an input, precondition catches impossible combinations before acting, postcondition catches violated expectations after acting. Use each at the earliest layer where the fact is knowable. A common follow-up question is what happens when a postcondition fails during apply: the resource exists, the run stops, and you fix forward — nothing is rolled back. If the assertion needed to prevent creation, it was a precondition's job, and moving it there is the correct fix rather than adding retry logic around a run that behaved as designed.
Quiz
1. A variable's validation block contains `condition = can(regex("^sg-[0-9a-f]+$", var.security_group_id))`. What happens when the caller passes "default"?
2. On Terraform 1.8, a validation condition references `var.create_cluster` and plan fails with "Invalid reference in variable validation". What are the correct responses?
3. You need to assert that a newly created EC2 instance received a public IP, using an attribute reported by the resource itself. Which construct fits?
4. During `terraform apply`, a lifecycle precondition on a resource evaluates to false. What does Terraform do?
Flashcards
Question
What two arguments does a validation block require, and what must condition evaluate to?
Click to reveal answer
Answer
condition (a boolean expression) and error_message (a literal string, no dynamic interpolation). The condition must be true for the run to proceed; false raises the error_message during validate, plan, or apply.
Question
How do you test a format in a validation block without crashing when the input is malformed?
Click to reveal answer
Answer
Wrap the raising function in can(...), e.g. `can(regex("^sg-[0-9a-f]+$", var.id))`. regex errors on no-match, can catches the error and returns false, so the condition fails normally and error_message is shown.
Question
What changed about input variable validation in Terraform 1.9, and what is the pre-1.9 workaround?
Click to reveal answer
Answer
Validation conditions became general expressions that may reference other input variables, locals, and data resources (previously only the variable being validated — otherwise "Invalid reference in variable validation"). Pre-1.9 workaround: move the cross-variable rule to a lifecycle precondition on a resource or null_resource.
Question
When does a lifecycle precondition run, and when does a postcondition run?
Click to reveal answer
Answer
Precondition: evaluated during plan when its inputs are known, and again during apply before the resource's create/update — it blocks the operation. Postcondition: evaluated after the resource operation, asserting results through self.<attribute>; a failure means the object exists but violated the contract (no automatic rollback).
Question
Which check should gate an output value, and what does it affect?
Click to reveal answer
Answer
A precondition inside the output block: it runs when the output is evaluated and fails the run if the condition is false — a clean way to refuse exporting a null endpoint when a conditional sub-module was disabled. It does not change the resource graph, only whether the value may be read.
Revision Notes
Key Takeaways
- 1. A validation block needs a boolean condition and a literal error_message; it runs at validate/plan, before any provider API call
- 2. can() turns function errors into false, which is how format checks on regex, cidrhost, and jsondecode become ordinary failing conditions
- 3. Before Terraform 1.9 a validation condition could reference only its own variable — cross-variable rules need 1.9+ or a precondition workaround
- 4. Preconditions block an operation (plan-time and re-checked at apply); postconditions assert outcomes after apply using self
- 5. Put each rule at the layer that owns the consequence: child-module validation travels with the module and catches misuse for every caller
Interview Tips
- • "How do you make a module hard to misuse?" — type constraint first, then validation rules with actionable error messages, then preconditions for combinations that only matter at plan; name which layer catches which class of mistake
- • "What is the difference between a precondition and a postcondition?" — timing: precondition before the resource operation and re-evaluated at plan, postcondition after it via self; also note Terraform does not roll back on postcondition failure
- • "When would validation not be enough?" — when the rule spans variables before 1.9, or when the fact lives in real infrastructure (an AMI, an existing CIDR); both move the check to a precondition that can see data sources and other resources
- • "Why does my validation rule error with 'Invalid reference'?" — cite the pre-1.9 self-reference restriction and give both fixes: upgrade to 1.9 or relocate to a lifecycle precondition; this shows you know the version history, not just the syntax
Cheat Sheet
Variable Validation & Condition Blocks
Validation block:
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod." # literal only
}
}
Multiple validation blocks per variable — all run; first failure aborts.
Common conditions:
| Rule | Expression |
|---|---|
| allow-list | contains([...], var.x) |
| format | can(regex("^pattern$", var.x)) |
| numeric range | var.min >= 1 && var.max <= var.min |
| required when enabled | !var.enabled || length(var.list) > 0 |
| cross-variable | var.create || length(var.endpoint) > 0 — needs Terraform 1.9+ |
Check ladder:
type → input shape · validation → input meaning · precondition → impossible combination before acting · postcondition → violated result after acting (self.<attr>).
Where checks run: validation: validate/plan/apply · precondition: plan + before apply op · postcondition: after apply op · output precondition: when output is evaluated.
Pre-1.9 fallback: cross-variable rule → lifecycle.precondition (evaluates later; may run alongside partial progress).