Variables, Types, Validation, and Local Values
Terraform variables, type constraints, validation rules, and local values are the language features that turn a collection of resources into a usable module interface. Variables accept values from callers, command-line flags, environment variables, or tfvars files. Types describe the shape of those values. Validation rejects values that are syntactically valid but unacceptable for your design. Local values name derived expressions so the rest of the configuration stays consistent and readable.
The outcome is practical: after this lesson, you should be able to design a module input contract, predict how Terraform evaluates values, choose between a variable and a local value, and diagnose common errors before they become confusing plans. This connects directly to the Terraform language section of the course because variables and locals are part of the expression model Terraform uses before it builds the resource graph.
How Terraform Evaluates Input Values
A variable block declares an input slot. Terraform assigns that slot a value during the planning operation. The value can come from a default, a terraform.tfvars file, an explicitly named var-file, a -var command-line argument, or an environment variable named with the TF_VAR_ prefix. When more than one source supplies a value, Terraform applies its documented precedence rules so that more explicit invocation-time values override less explicit defaults.
After Terraform finds a candidate value, it checks the declared type. Type constraints are not comments; they are part of Terraform’s decoding step. A variable declared as string accepts one string. A variable declared as list(string) accepts an ordered sequence of strings. A variable declared as map(object({ cidr = string, public = bool })) accepts a mapping whose values are objects with exactly that expected shape, subject to optional attributes if you declare them.
Validation blocks run after the variable value can be referenced as var.name. A validation condition must return true for acceptable input. It is where you enforce domain rules that the type system cannot express by itself, such as allowed environment names, a CIDR prefix length range, a naming convention, or a relationship between fields inside one variable value.
Local values are different. A locals block does not create caller-provided input. It gives a name to an expression inside the current module. Terraform evaluates local values from expressions, and the references are dependency-aware. A local can depend on variables, other locals, data sources, or resource attributes, although locals that depend on resource attributes may be unknown until planning has enough provider information. Locals are immutable within a module: you do not assign to the same local later as a procedural program would.
Syntax Anatomy
A variable block has a label, optional metadata, an optional default, an optional type constraint, a sensitivity flag, nullability behavior, and zero or more validation blocks. The label becomes the reference name under var. A local value is declared inside a locals block and referenced under local. Both participate in Terraform expressions, but they serve opposite sides of the module boundary: variables are public inputs, locals are private derived names.
variable "environment" {
description = "Deployment environment name."
type = string
default = "dev"
nullable = false
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be one of: dev, stage, prod."
}
}
locals {
name_prefix = "app-${var.environment}"
}
This declares a string input and then derives a reusable prefix. If a caller passes environment = "qa", Terraform fails during input validation before it proposes any resource changes. If the caller omits the value, the default dev is used and local.name_prefix becomes app-dev.
Example 1: A Simple Environment Contract
The smallest useful pattern is a variable with an allow-list and a local derived from it. The type system verifies that the value is a string. The validation rule verifies that the string is one of the environments this module supports. The local centralizes formatting so resource names do not each reimplement the same expression.
variable "environment" {
type = string
description = "One of the supported deployment environments."
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "Use dev, stage, or prod."
}
}
locals {
bucket_name = "logs-${var.environment}"
}
output "bucket_name" {
value = local.bucket_name
}
With environment = "stage", the output is deterministic: logs-stage. With environment = "test", Terraform reports the custom validation error. The design choice is deliberate: unknown environment names are refused at the module boundary instead of silently producing names that no one expects.
Example 2: Object Types for Structured Input
As modules grow, separate variables can hide relationships between values. An object type keeps related settings together. Optional attributes let the module provide a default for a field while still requiring the caller to provide the fields that matter most.
variable "service" {
type = object({
name = string
port = number
public = bool
health_path = optional(string, "/health")
instance_size = optional(string, "small")
})
validation {
condition = var.service.port >= 1 && var.service.port <= 65535
error_message = "service.port must be a valid TCP port number."
}
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,30}$", var.service.name))
error_message = "service.name must be 3-31 lowercase letters, numbers, or hyphens, starting with a letter."
}
}
locals {
service_id = "${var.service.name}-${var.service.port}"
}
If the caller supplies { name = "api", port = 8080, public = true }, Terraform fills health_path with /health, fills instance_size with small, and sets local.service_id to api-8080. If the caller supplies port 70000, type checking succeeds because it is still a number, but validation fails because it is outside the allowed TCP range.
Example 3: Maps, Normalization, and Reusable Tags
Maps are useful when a module accepts keyed collections, such as per-environment settings. Locals are then useful for normalizing the chosen environment into the exact values resources should consume.
variable "environment" {
type = string
validation {
condition = contains(keys(var.subnet_cidrs), var.environment)
error_message = "environment must have a matching entry in subnet_cidrs."
}
}
variable "subnet_cidrs" {
type = map(list(string))
}
variable "extra_tags" {
type = map(string)
default = {}
}
locals {
selected_cidrs = var.subnet_cidrs[var.environment]
common_tags = merge(
{
Environment = var.environment
ManagedBy = "Terraform"
},
var.extra_tags
)
}
output "selected_cidrs" {
value = local.selected_cidrs
}
output "common_tags" {
value = local.common_tags
}
For environment = "prod" and subnet_cidrs = { prod = ["10.0.0.0/24", "10.0.1.0/24"] }, local.selected_cidrs is that two-item list. local.common_tags contains the standard tags plus any caller-provided extra tags. This pattern trades flexibility for clarity: callers can add tags and environment-specific CIDRs, but the module keeps ownership of required tag keys and selection logic.
Design Choices and Trade-offs
Prefer a variable when the caller genuinely needs to make a choice. Prefer a local when the value is derived from other values, repeated in several places, or part of module implementation detail. Exposing too many variables makes a module hard to call correctly. Hiding real choices inside locals makes the module hard to reuse. A good interface exposes decisions, not incidental formatting.
Use precise types for module inputs. any can be useful for pass-through data, but it disables much of Terraform’s early feedback. Separate variables are easy to override individually, while object variables make relationships clearer and reduce long parameter lists. Validation improves error messages, but it should not duplicate everything the type system already checks. Let list(string) reject non-strings; use validation for business rules such as allowed values, ranges, naming policy, or relationships within one variable.
Sensitive variables hide values from normal CLI output, but they do not make secrets harmless. Sensitive values can still end up in state if a resource argument stores them. For secrets, combine sensitive = true with appropriate state backend access controls and avoid exposing secret outputs.
Failure Modes and Troubleshooting
Symptom: Terraform says a value is unsuitable for a variable because an attribute is missing. Cause: the object type requires an attribute the caller did not provide. Diagnose: inspect the variable type and the tfvars value side by side, paying attention to object keys. Correct: add the missing field or mark it optional(...) with a sensible default if the module can safely choose one.
Symptom: a validation rule crashes with an error instead of returning the custom message. Cause: the condition evaluated an expression that can fail, such as regex on an unexpected value. Diagnose: simplify the expression in terraform console. Correct: wrap risky checks with can(...) or try(...) so the validation condition returns a boolean.
Symptom: a local value is reported as unknown during planning. Cause: the local depends on a resource attribute that is computed by the provider only after apply. Diagnose: follow the references in the local expression. Correct: avoid using apply-time values in places that must be known during planning, such as for_each keys, or derive the value from stable input variables instead.
Symptom: different workstations produce different plans. Cause: values are coming from different tfvars files, shell environment variables, or command-line flags. Diagnose: check for TF_VAR_ environment variables and compare the exact plan command. Correct: standardize var-file usage in automation and keep environment-specific values in reviewed files.
Security, Performance, and Reliability
Variables define a module’s public surface, so they affect security and reliability. Validation can prevent accidental public exposure, unsupported regions, invalid CIDRs, or unsafe naming before Terraform contacts a provider. Sensitive variables reduce accidental display, but protected remote state is still required when values are stored in state. Locals improve reliability by removing duplicated expressions; when one naming rule changes, there is one expression to update rather than several slightly different copies.
Performance impact is usually small, but very large nested variables and complex comprehensions can slow plan readability and increase evaluation work. More importantly, complex expressions can make plans hard to review. Keep locals named after domain concepts, not after implementation tricks. A local named public_subnet_ids is easier to review than one named computed_list_1.
Hands-on Lab
Prerequisites: Terraform installed locally, an empty working directory, and no cloud credentials required. This lab uses variables, locals, outputs, and validation only, so terraform plan can run without a provider.
- Create a directory named
tf-input-lab. - Add a
main.tffile containing the lab configuration below. - Run
terraform init. - Run
terraform plan -var='environment=dev' -var='service_name=api'. - Verify that the planned outputs include
name = "api-dev"and a tag map containingManagedBy = "Terraform". - Run
terraform plan -var='environment=qa' -var='service_name=api'and confirm Terraform rejects the environment. - Run
terraform plan -var='environment=dev' -var='service_name=API'and confirm Terraform rejects the service name. - Cleanup by deleting the lab directory. No remote resources are created.
variable "environment" {
type = string
description = "Deployment environment."
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be dev, stage, or prod."
}
}
variable "service_name" {
type = string
description = "Lowercase service identifier."
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,20}$", var.service_name))
error_message = "service_name must be 3-21 lowercase letters, numbers, or hyphens, starting with a letter."
}
}
variable "extra_tags" {
type = map(string)
default = {}
}
locals {
name = "${var.service_name}-${var.environment}"
tags = merge(
{
Environment = var.environment
ManagedBy = "Terraform"
Service = var.service_name
},
var.extra_tags
)
}
output "name" {
value = local.name
}
output "tags" {
value = local.tags
}
terraform init
terraform plan -var='environment=dev' -var='service_name=api'
terraform plan -var='environment=qa' -var='service_name=api'
The first plan should succeed and show output values to be created. The second plan command should fail during variable validation with the environment error message. Because this configuration contains no resources, verification is limited to Terraform’s evaluation and output preview, which is exactly what this lab is intended to test.
Assessment Exercises
- A module has variables named
region,primary_region, andreplica_region. Which should be variables and which, if any, should be locals? Explain the module boundary you would design. - Rewrite a loose
map(any)variable for service configuration as an object type with at least one optional attribute and one validation rule. - A validation rule uses
regexand fails with an expression error instead of the custom error message. Show howcanchanges the behavior. - Given a repeated expression used in five resource names, decide whether to create a local. What would make the local helpful, and what would make it unnecessary?
- Design a validation rule that prevents an input map from being empty and explain why the type system alone cannot express that rule.
Summary
Variables are Terraform’s module inputs, type constraints define input shape, validation blocks enforce domain rules, and local values name derived expressions inside the module. Use strong types for early feedback, validation for rules that types cannot express, and locals for consistency and readability. The best Terraform modules expose real choices to callers while keeping derived implementation details private and deterministic.
