TerraformXIII · Variables, Outputs, and LocalsProduction Terraform
Variable Types and Validation
What you'll learn
- Choose the narrowest type that still accepts every valid input
- Distinguish primitive, collection, and structural types
- Write validation blocks that fail at plan time rather than at apply time
- Recognise the limits of Terraform automatic type conversion
- Document the type contract for each input in the variable description
Prerequisites
None — start here.
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
Every input to a Terraform configuration is a contract. The type = ...
line is the type system enforcing that contract at plan time, before the
configuration reaches the provider API. Get the type wrong and the apply
fails, sometimes ambiguously. Get the validation wrong and the failure
surfaces forty minutes into a partial apply against a production account.
A real production incident: the team declared a CIDR block as string
instead of string with a regex, and a typo (10.0.1.0/34) survived
validation. The apply reached AWS, which accepted the malformed block, and
the resulting route table silently dropped traffic for two days. Types and
validations are not paperwork. They are the cheapest control layer.
Type categories
Terraform’s type system is small. Three categories cover the variable types you will meet in production:
Terraform variable types
|
+---------------------+---------------------+
| | |
primitive collection structural
| | |
string, number, list, set, map object, tuple
bool
Primitives. string, number, bool. Single values. These are the
inputs you read most often: region names, instance counts, feature flags.
Collections. list(T), set(T), map(T). Zero or more values of a
single element type. list is ordered and may contain duplicates; set
is unordered and rejects duplicates; map is keyed by strings.
Structural. object({...}), tuple([...]). Zero or more values of
explicitly declared types. object is keyed; tuple is positional.
# CONFIGURATION
variable "region" {
type = string
description = "AWS region. One of eu-west-2, us-east-1, ap-southeast-2."
default = "eu-west-2"
}
variable "instance_count" {
type = number
description = "Number of application instances. Must be >= 1."
default = 2
}
variable "enable_backups" {
type = bool
description = "Enable automated nightly backups. Default true."
default = true
}
variable "availability_zones" {
type = list(string)
description = "AZs in which to spread subnets. Order matters for az-mapping."
default = ["eu-west-2a", "eu-west-2b", "eu-west-2c"]
}
variable "cidr_allow_list" {
type = set(string)
description = "CIDR ranges allowed to reach the admin endpoint. Deduped."
default = []
}
variable "service_ports" {
type = map(number)
description = "Port mappings keyed by service name."
default = {
http = 80
https = 443
}
}
variable "db_config" {
type = object({
engine = string
engine_version = string
storage_gb = number
multi_az = bool
})
description = "Database engine configuration block."
}
variable "lb_config" {
type = tuple([string, number, string])
description = "Tuple of (protocol, port, target protocol)."
default = ["tcp", 443, "tcp"]
}
WhyThisMatters
WhyThisMatters The variable type = ... line is the only barrier between
a typo and a production apply. Every variable without an explicit type
defaults to any, which means Terraform accepts any value the operator
can produce. In a 200-variable module that is 200 latent bugs.
Conversion rules
Terraform converts types at the variable boundary, not inside the configuration. The rules are conservative and silent by design. They are also the source of the most common type bugs.
| Source value | Target type | Behaviour |
|---|---|---|
"443" | number | Coerced to 443 |
"true" | bool | Coerced to true |
"TRUE" | bool | Not coerced — error |
443 | string | Not coerced — error |
[1, 2, 3] | tuple([number, number, number]) | Not coerced — must match exactly |
["a", "b"] | set(string) | Coerced; duplicates collapse |
{ a = 1 } | object({ a = number }) | Not coerced — schema must match |
The rule of thumb: strings convert to numbers and bools when the source
is unambiguous. Lists and maps do not convert to structural types. The
operator who expects list(string) to be accepted where the provider
expects tuple([string]) will be surprised.
Validation blocks
A validation block runs at plan time. It is the cheapest place to catch
a malformed input. The reference scope is wider than default: it can
reference other variables, locals, and built-in functions. It cannot
reference resources or data sources.
# CONFIGURATION
variable "environment" {
type = string
description = "Deployment environment."
validation {
condition = contains(["dev", "staging", "production"], var.environment)
error_message = "Environment must be one of: dev, staging, production."
}
}
variable "cidr_block" {
type = string
description = "Primary VPC CIDR block."
validation {
condition = can(cidrnetmask(var.cidr_block))
error_message = "Must be a valid CIDR block (e.g. 10.0.0.0/16)."
}
}
variable "instance_count" {
type = number
description = "Number of application instances."
validation {
condition = var.instance_count >= 1 && var.instance_count <= 20
error_message = "Instance count must be between 1 and 20."
}
}
The validation runs once per terraform plan. A failed validation aborts
the plan before any provider API call. That is the production value: the
typo is caught in five seconds, not in five minutes.
Nullable and optional
Terraform 1.1 introduced nullable and 1.3 introduced optional(). The
defaults matter.
nullable = true— the variable acceptsnullas a valid value.nullable = false(the default) — passingnulltriggers an error.optional()type modifier — the variable may be omitted entirely.
# CONFIGURATION
variable "alert_email" {
type = string
default = null
nullable = true
validation {
condition = var.alert_email == null || can(regex(".+@.+", var.alert_email))
error_message = "Must be a valid email address or null."
}
}
The combination of default = null, nullable = true, and a null-aware
validation is the production pattern for optional contact points.
Failure modes
Six failures that surface repeatedly in production:
-
listwheresetwas intended. The configuration accepts duplicates that the downstream resource treats as a single value. The apply succeeds; the resource count is wrong. -
stringwhereboolwas intended."true"coerces silently;"True"and"yes"do not. The apply fails partway through a stack with a string-as-bool error from the provider. -
Object schema drift. A consumer adds a new field to the
objecttype but the producer’s variable block is older. The plan errors with “Invalid value for …” and the operator doesn’t know which side is wrong. -
validationreferencing an unknown value. The validation block referencesdata.aws_caller_identity.current.account_id. The data source is not yet resolved at plan time. The validation either errors or is silently skipped, depending on Terraform version. -
tuplelength mismatch. The provider expects exactly three elements; the configuration provides two. The apply errors at the resource, not at the variable. -
Number coercion of strings from env vars.
TF_VAR_port="443"coerces to443.TF_VAR_ports="443,8443"coerces the whole string to a number and errors.
Security and performance
Security. Type validation is not a security control. It cannot
prevent an injection-style input — that is the job of sensitive,
secrets managers, and input sanitisation at the application boundary.
Type validation is the cheapest correctness control.
Performance. Validation runs at every plan. A validation block that
calls regex against a 10 MB string is slower than one that calls
contains. Keep validations cheap; reserve expensive checks for the
resource that consumes the value.
Production guidance
- Set
type = ...on every variable. Never use the defaultany. - Add at least one
validationblock per variable that has business semantics (region, environment, instance count, CIDR, ARN format). - Use
nullable = truefor optional contact points (email, webhook URL). - Use
optional()for fields that may be omitted in module inputs. - Keep
descriptionaccurate; the description is the contract.
What comes next
The next lesson in XIII-Variables is Variable Input Precedence — where the value comes from when the operator, the CI pipeline, and the configuration all want to set it.
Verification
- You declared
variable "ports" { type = list(number) }and the operator passes[80, "443", 8080]. Does Terraform coerce"443"to a number? Why? - A
validationblock referencesdata.aws_caller_identity.current.account_id. At what stage does it run, and what happens if the data source cannot be resolved? - Why is
type = anya footgun in a 200-variable module? - The provider expects
tuple([string, number, string])and you declarelist(any). At which point does the apply fail and why?
Knowledge check · 7 questions
Q1. What does omitting `type = ...` in a variable block default to?
Q2. Which collection type rejects duplicate elements?
Q3. Terraform automatically converts a `list` to a `tuple` when the provider requires a tuple.
Q4. When does a `validation` block execute?
Q5. Which of the following are valid HCL variable types? (Select all that apply.)
Q6. A provider rejects your input because it received the string `"443"` instead of the number `443`. What is the most likely cause?
Q7. A `validation` block references `data.aws_caller_identity.current.account_id`. What is the most likely outcome?
Passing score: 75%. Answers are checked in this browser.