Variable Types & Constraints
A variable’s type argument is a contract: it tells Terraform exactly what shape of value a caller is allowed to pass, and it rejects anything that does not fit before a single resource is touched. Typing your variables turns a whole class of mistakes — a string where a number belongs, a missing object field, a list passed as a single value — into clear errors during plan rather than confusing provider failures during apply. This page covers the full range of type constraints you can put on an input variable, from primitives to deeply nested objects, and works identically in Terraform 1.5+ and OpenTofu, which share the HCL2 type system.
Why constrain variable types
When you omit type, a variable defaults to any, and Terraform accepts whatever it receives. That feels convenient until a caller passes "3" where you expected 3, or a flat string where your module iterates a list. The error then surfaces deep inside a resource block with a message about the provider, not your interface. A concrete type constraint moves that failure forward to the boundary of your module and points the error at the offending input.
variable "instance_count" {
type = number
description = "Number of EC2 instances to launch."
}
Pass instance_count = "many" and Terraform stops immediately.
Output:
│ Error: Invalid value for input variable
│
│ on variables.tf line 1:
│ 1: variable "instance_count" {
│
│ The given value is not suitable for var.instance_count: a number is
│ required.
Primitive type constraints
The three primitives — string, number, and bool — are the simplest constraints and the ones you will use most. Terraform converts safely between them when the conversion is unambiguous, so "true" is accepted for a bool and "443" for a number, but a non-numeric string is rejected.
variable "cluster_name" {
type = string
default = "prod-cluster"
}
variable "desired_capacity" {
type = number
default = 3
}
variable "enable_deletion_protection" {
type = bool
default = false
}
Collection type constraints
Collections hold many values of the same element type. You declare them with a type constructor that names the element type in parentheses.
| Constraint | Holds | Access by | Allows duplicates |
|---|---|---|---|
list(<TYPE>) | ordered sequence | integer index | yes |
set(<TYPE>) | unordered collection | (none) | no |
map(<TYPE>) | string-keyed key/value pairs | string key | keys are unique |
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "ingress_ports" {
type = set(number)
default = [22, 80, 443]
}
variable "common_tags" {
type = map(string)
default = {
Environment = "production"
ManagedBy = "terraform"
}
}
A set is the natural choice when you feed the value to for_each, since for_each requires a set (or map) and duplicates are meaningless.
Object and tuple constraints
Structural types describe values where different positions hold different types. An object is a typed struct of named attributes; a tuple is an ordered, fixed-length sequence with a type per position. Objects are the workhorse for module configuration because they group related settings into a single, self-documenting variable.
variable "database" {
type = object({
engine = string
instance_class = string
allocated_gb = number
multi_az = bool
})
}
A caller must supply every attribute with the right type. Passing a map that is missing multi_az, or that types allocated_gb as a string, fails validation with a message naming the bad attribute.
You can nest constructors freely. A list of subnet objects, for example, is a precise interface for a networking module:
variable "subnets" {
type = list(object({
cidr_block = string
availability_zone = string
public = bool
}))
}
Optional object attributes with defaults
Requiring every object field is rigid. The optional() modifier marks an attribute as not required and, with a second argument, supplies a default when the caller omits it. This is the idiomatic way to expose advanced knobs without forcing callers to set them.
variable "bucket_config" {
type = object({
name = string
versioning = optional(bool, false)
encryption = optional(string, "AES256")
lifecycle_days = optional(number)
})
}
If a caller passes only { name = "app-logs" }, Terraform fills in versioning = false and encryption = "AES256". An optional() attribute without a default — like lifecycle_days above — becomes null when omitted, which you can test for in your resource logic.
resource "aws_s3_bucket_lifecycle_configuration" "logs" {
count = var.bucket_config.lifecycle_days != null ? 1 : 0
bucket = aws_s3_bucket.logs.id
rule {
id = "expire"
status = "Enabled"
expiration {
days = var.bucket_config.lifecycle_days
}
}
}
Tip: Defaults in
optional()apply recursively. If a nested object attribute is itself optional and omitted, Terraform fills its own optional defaults too — so a caller can pass a shallow object and still get a fully populated structure.
The any constraint
any matches any type and is the implicit default when you write no type at all. Terraform unifies the concrete type at runtime. It is occasionally useful for genuinely polymorphic inputs or pass-through wrappers, but every any is validation you have chosen to give up.
variable "extra_tags" {
type = map(any)
default = {}
}
Prefer map(string) or a concrete object({...}) whenever you actually know the shape — the constraint is free documentation and a free guardrail.
Best Practices
- Always declare an explicit
type; never let a variable silently fall back toany. - Model related settings as a single
object({...})variable instead of many loose scalars — it documents the interface and keeps call sites tidy. - Use
optional()with sensible defaults so callers configure only what they need. - Choose
setoverlistwhen order does not matter and you will pass the value tofor_each. - Reserve
anyfor truly dynamic inputs; reach for it only after a concrete constraint proves impossible. - Combine type constraints with
validationblocks for rules that types alone cannot express, such as allowed string values or numeric ranges. - Give every typed variable a
descriptionso the generated docs explain both the shape and the intent.