Type & Validation #

A variable without a type constraint and validation is just a placeholder — anyone can put in any value, and the error only surfaces when apply is already running and the provider tries to create a resource with the wrong value. Type constraints and validation rules move error detection earlier: to the terraform plan stage, before a single API call is made. This is the difference between failing fast locally and failing slowly in production.

flowchart TD
    A["Terraform Data Types"] --> B["Primitive Types\nstring, number, bool"]
    A --> C["Collection Types\nlist, map, set"]
    A --> D["Object Types\nobject({...})"]
    B --> E["Single & simple values"]
    C --> F["Collections of the same type"]
    D --> G["Structures with varying fields"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#10b981,stroke:#059669,color:#fff
    style C fill:#f59e0b,stroke:#d97706,color:#fff
    style D fill:#8b5cf6,stroke:#6d28d9,color:#fff

Type Constraints in Detail #

A type constraint ensures a variable accepts the appropriate data type. Terraform will error at plan time if the provided value doesn’t match the declared type.

# PRIMITIVE TYPES with strict constraints

variable "environment" {
  type = string
  # Accepts: "dev", "production", "staging-01"
  # Rejects: 42, true, ["dev"]
}

variable "replica_count" {
  type = number
  # Accepts: 3, 1.5, 0
  # Rejects: "three", true
}

variable "enable_deletion_protection" {
  type = bool
  # Accepts: true, false
  # Also accepts: "true", "false", "1", "0" (Terraform auto-converts)
}
# COLLECTION TYPES with element constraints

variable "allowed_cidrs" {
  type        = list(string)
  description = "CIDR blocks allowed access"
  # Accepts: ["10.0.0.0/8", "192.168.0.0/16"]
  # Rejects: ["10.0.0.0/8", 42]  ← element isn't a string
}

variable "instance_ports" {
  type        = map(number)
  description = "Mapping of service names to port numbers"
  # Accepts: { http = 80, https = 443, grpc = 9090 }
  # Rejects: { http = "eighty" }  ← value isn't a number
}

variable "unique_regions" {
  type        = set(string)
  description = "Set of unique regions (duplicates auto-removed)"
  # Accepts: ["ap-southeast-1", "us-east-1"]
  # Duplicates in a set are automatically deduplicated by Terraform
}
# OBJECT — the most detailed type constraint
variable "rds_config" {
  type = object({
    engine            = string
    engine_version    = string
    instance_class    = string
    allocated_storage = number
    multi_az          = bool
    backup_retention  = optional(number, 7)  # Optional field with a default
  })

  default = {
    engine            = "postgres"
    engine_version    = "15.3"
    instance_class    = "db.t3.medium"
    allocated_storage = 100
    multi_az          = false
    # backup_retention doesn't need to be set — it has a default of 7
  }
}

Validation Rules #

Validation rules let you define constraints more specific than just data types — for example, “this string must be one of the allowed values” or “this number must be greater than 0”.

variable "environment" {
  type        = string
  description = "Deployment environment name"

  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be one of: dev, staging, production."
  }
}

# Try setting environment = "prod" → error at plan:
# ╷
# │ Error: Invalid value for variable
# │
# │   on variables.tf line 1:
# │    1: variable "environment" {
# │
# │ Environment must be one of: dev, staging, production.
# │
# │ This was checked by the validation rule at variables.tf:6,3-13.
# ╵
variable "replica_count" {
  type        = number
  description = "Number of replicas for the deployment"

  validation {
    condition     = var.replica_count >= 1 && var.replica_count <= 20
    error_message = "The replica count must be between 1 and 20."
  }
}

variable "vpc_cidr" {
  type        = string
  description = "CIDR block for the main VPC"

  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "The VPC CIDR must be a valid CIDR block, e.g. 10.0.0.0/16."
  }
}
flowchart TD
    A["📥 Input Variable"] --> B{"Check Data Type"}
    B -->|"Wrong Type"| C["❌ Error:\ntype mismatch"]
    B -->|"Right Type"| D{"Check Validation\nRule 1"}
    D -->|"Failed"| E["❌ Error:\nmessage from the validation block"]
    D -->|"Passed"| F{"Check Validation\nRule N..."}
    F -->|"Failed"| E
    F -->|"Passed"| G["✅ Value accepted\nProceed to plan"]

    style A fill:#3b82f6,stroke:#1e40af,color:#fff
    style B fill:#f59e0b,stroke:#d97706,color:#fff
    style C fill:#ef4444,stroke:#dc2626,color:#fff
    style D fill:#f59e0b,stroke:#d97706,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style F fill:#f59e0b,stroke:#d97706,color:#fff
    style G fill:#10b981,stroke:#059669,color:#fff

Multiple Validation Blocks #

A single variable can have more than one validation block — each checking a different condition.

variable "instance_type" {
  type        = string
  description = "EC2 instance type"

  # Validation 1: Must start with an approved prefix
  validation {
    condition     = can(regex("^(t3|t4g|m5|m6i|c5|c6i|r5|r6i)\\.", var.instance_type))
    error_message = "Instance type must use an approved family (t3, t4g, m5, m6i, c5, c6i, r5, r6i)."
  }

  # Validation 2: Must not use nano or micro sizes in production
  validation {
    condition     = !can(regex("\\.(nano|micro)$", var.instance_type))
    error_message = "nano and micro instance sizes aren't allowed — use at least small."
  }
}

Validation Referencing Other Variables #

A validation block can reference the variable itself, but can’t reference other variables. For cross-variable validation, use locals.

# ANTI-PATTERN: Trying to reference another variable in validation
variable "max_size" {
  type = number

  validation {
    # ✗ Can't do this — var.min_size isn't accessible here
    condition     = var.max_size >= var.min_size
    error_message = "max_size must be >= min_size."
  }
}

# CORRECT: Use locals for cross-variable validation
variable "min_size" {
  type = number
}

variable "max_size" {
  type = number
}

locals {
  # Cross-variable validation using a local value with a condition
  validate_size_range = (
    var.max_size >= var.min_size
    ? null
    : tobool("max_size (${var.max_size}) must be greater than or equal to min_size (${var.min_size})")
  )
}

Common Validation Patterns in Production #

# Validate the AWS resource ID format
variable "vpc_id" {
  type = string

  validation {
    condition     = can(regex("^vpc-[a-f0-9]{8,17}$", var.vpc_id))
    error_message = "vpc_id must be formatted as 'vpc-' followed by 8-17 hex characters."
  }
}

# Validate a safe name for resource naming
variable "project_name" {
  type = string

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,30}[a-z0-9]$", var.project_name))
    error_message = "project_name must be lowercase, start with a letter, only letters/numbers/hyphens, 4-32 characters long."
  }
}

# Validate a sensitive value isn't empty
variable "db_password" {
  type      = string
  sensitive = true

  validation {
    condition     = length(var.db_password) >= 16
    error_message = "The database password must be at least 16 characters."
  }
}

# Validate a CIDR doesn't overlap with a forbidden range
variable "app_cidr" {
  type = string

  validation {
    condition     = can(cidrhost(var.app_cidr, 0)) && !startswith(var.app_cidr, "169.254.")
    error_message = "app_cidr must be a valid CIDR and not link-local (169.254.x.x)."
  }
}


Complex Type Validation #

Terraform supports validation for complex types like objects and lists.

variable "database_config" {
  type = object({
    engine         = string
    engine_version = string
    instance_class = string
    multi_az       = bool
    storage_gb     = number
  })

  validation {
    condition     = contains(["mysql", "postgres", "mariadb"], var.database_config.engine)
    error_message = "Engine must be mysql, postgres, or mariadb."
  }

  validation {
    condition     = var.database_config.storage_gb >= 20 && var.database_config.storage_gb <= 1000
    error_message = "Storage must be between 20 and 1000 GB."
  }
}

variable "allowed_ports" {
  type = list(number)

  validation {
    condition     = alltrue([for p in var.allowed_ports : p > 0 && p < 65536])
    error_message = "All ports must be between 1 and 65535."
  }
}

Advanced Validation Patterns #

# Regex validation for a specific format
variable "domain_name" {
  type = string
  validation {
    condition     = can(regex("^[a-z0-9][a-z0-9.-]+[a-z0-9]$", var.domain_name))
    error_message = "The domain name must be valid (e.g. app.example.com)"
  }
}

# Validation with a length check
variable "allowed_cidrs" {
  type = list(string)
  validation {
    condition     = length(var.allowed_cidrs) > 0 && length(var.allowed_cidrs) <= 50
    error_message = "The number of CIDRs must be between 1 and 50."
  }
}

# Validation with alltrue for every element
variable "subnet_cidrs" {
  type = list(string)
  validation {
    condition     = alltrue([for cidr in var.subnet_cidrs : can(cidrhost(cidr, 0))])
    error_message = "All values must be valid CIDR blocks."
  }
}

Nullable Variables #

# Terraform 1.1+ supports nullable parameters
# Nullable = true (default): a null value uses the default
# Nullable = false: a null value is passed through to the module/resource

variable "optional_tags" {
  type    = map(string)
  default = {}
  nullable = false
  # If the caller doesn't provide a value, null is passed through
  # Useful for optional arguments that must be explicitly null
}

# Usage example:
variable "ami_override" {
  type     = string
  default  = null
  nullable = true
  # If null, the resource will use a data source
}

locals {
  ami_id = var.ami_override != null ? var.ami_override : data.aws_ami.latest.id
}

Object Type Variables #

# Complex object type
variable "database_config" {
  description = "Database configuration"
  type = object({
    engine         = string
    engine_version = string
    instance_class = string
    allocated_storage = number
    multi_az       = bool
    backup_retention = number
  })
  
  default = {
    engine         = "postgres"
    engine_version = "15.4"
    instance_class = "db.t3.micro"
    allocated_storage = 20
    multi_az       = false
    backup_retention = 7
  }
}

# Optional attributes (Terraform 1.3+)
variable "server_config" {
  type = object({
    name     = string
    port     = optional(number, 8080)
    protocol = optional(string, "https")
  })
}

Summary #

  • Type constraints catch wrong types at plan time — far better than errors at apply when resources are already partially created.
  • object() with optional() for variables with many sub-fields where some fields can be left empty.
  • Validation blocks for semantic constraints — values that are type-correct but business-invalid: environment may only be “dev/staging/production”, CIDRs must be valid, minimum lengths, etc.
  • Multiple validation blocks to separate different conditions — error messages become more specific and helpful.
  • Cross-variable validation uses locals — a validation block can’t access other variables, but tobool() in locals can be used to create informative errors.
  • Investing in validation rules up front saves debugging time later — errors found at plan are far cheaper than errors found in production.

← Previous: What is a Variable?   Next: File & Environment →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact