Skip to main content
RunBook Academy

TerraformXIII · Variables, Outputs, and LocalsProduction Terraform

Variable Types and Validation

Foundation⏱ ~12 minbash

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

Not yet marked complete on this device.

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 valueTarget typeBehaviour
"443"numberCoerced to 443
"true"boolCoerced to true
"TRUE"boolNot coerced — error
443stringNot 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 accepts null as a valid value.
  • nullable = false (the default) — passing null triggers 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:

  1. list where set was intended. The configuration accepts duplicates that the downstream resource treats as a single value. The apply succeeds; the resource count is wrong.

  2. string where bool was 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.

  3. Object schema drift. A consumer adds a new field to the object type 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.

  4. validation referencing an unknown value. The validation block references data.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.

  5. tuple length mismatch. The provider expects exactly three elements; the configuration provides two. The apply errors at the resource, not at the variable.

  6. Number coercion of strings from env vars. TF_VAR_port="443" coerces to 443. 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 default any.
  • Add at least one validation block per variable that has business semantics (region, environment, instance count, CIDR, ARN format).
  • Use nullable = true for optional contact points (email, webhook URL).
  • Use optional() for fields that may be omitted in module inputs.
  • Keep description accurate; 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

  1. You declared variable "ports" { type = list(number) } and the operator passes [80, "443", 8080]. Does Terraform coerce "443" to a number? Why?
  2. A validation block references data.aws_caller_identity.current.account_id. At what stage does it run, and what happens if the data source cannot be resolved?
  3. Why is type = any a footgun in a 200-variable module?
  4. The provider expects tuple([string, number, string]) and you declare list(any). At which point does the apply fail and why?

Knowledge check · 7 questions

  1. Q1. What does omitting `type = ...` in a variable block default to?

  2. Q2. Which collection type rejects duplicate elements?

  3. Q3. Terraform automatically converts a `list` to a `tuple` when the provider requires a tuple.

  4. Q4. When does a `validation` block execute?

  5. Q5. Which of the following are valid HCL variable types? (Select all that apply.)

  6. Q6. A provider rejects your input because it received the string `"443"` instead of the number `443`. What is the most likely cause?

  7. 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.