Skip to content

Input Variables

An input variable is a named, typed parameter that lets the same Terraform configuration produce different infrastructure depending on who calls it and with what values.

A variable block has four parts worth knowing well: type constrains what shape of value is acceptable, default supplies a fallback when nobody sets a value, description documents intent for the next reader, and sensitive redacts the value from most CLI output.

variable "environment" {
type = string
description = "Deployment environment name, e.g. dev, staging, prod"
default = "dev"
}
variable "instance_count" {
type = number
description = "Number of EC2 instances to launch"
default = 2
}
variable "enable_monitoring" {
type = bool
description = "Whether to enable detailed CloudWatch monitoring"
default = true
}
variable "allowed_ports" {
type = list(number)
description = "TCP ports allowed inbound on the security group"
default = [22, 443]
}
variable "resource_tags" {
type = map(string)
description = "Tags applied to every resource in this configuration"
default = {
project = "payments"
team = "platform"
}
}
variable "db_config" {
type = object({
engine = string
instance_class = string
allocated_gb = number
})
description = "Structured configuration for the RDS instance"
default = {
engine = "postgres"
instance_class = "db.t3.micro"
allocated_gb = 20
}
}
variable "db_password" {
type = string
description = "Master password for the RDS instance"
sensitive = true
}

object({...}) is worth calling out: it is a typed, fixed-shape structure — every key you list is required (unless you give it a default with optional()), and Terraform rejects a caller-supplied value that has the wrong shape at plan time, well before any AWS API call happens.

Optional object attributes with optional()

Section titled “Optional object attributes with optional()”

Listing every key as required makes an object type brittle — a caller who only cares about engine still has to spell out every other field. optional(<type>, <default>) inside an object({...}) marks an attribute as not-required and supplies its own default, independent of the variable’s own top-level default:

variable "db_config" {
type = object({
engine = string
instance_class = optional(string, "db.t3.micro")
allocated_gb = optional(number, 20)
multi_az = optional(bool, false)
})
default = {
engine = "postgres"
}
}

A terraform console session against this exact block, evaluating var.db_config with only engine set — Terraform fills in every omitted optional() attribute from its own default:

$ echo 'var.db_config' | terraform console
{
"allocated_gb" = 20
"engine" = "postgres"
"instance_class" = "db.t3.micro"
"multi_az" = false
}

This is different from the plain object({...}) shown above: without optional(), a caller who omits instance_class gets a hard “attribute is required” error at plan time. With it, the object is genuinely partially-required — only engine must be supplied, and every other attribute has its own independent fallback.

Type-mismatch errors happen before any provider is called

Section titled “Type-mismatch errors happen before any provider is called”

A caller passing the wrong shape of value for a type never reaches the AWS provider — Terraform’s own type-checking rejects it first, with the exact variable and location named:

variable "instance_count" {
type = number
}
# terraform.tfvars: instance_count = "not-a-number"
$ terraform plan
Planning failed. Terraform encountered an error while generating this plan.
╷
│ Error: Invalid value for input variable
│
│ on terraform.tfvars line 1:
│ 1: instance_count = "not-a-number"
│
│ The given value is not suitable for var.instance_count declared at
│ main.tf:1,1-26: a number is required.
╵

nullable = false: forbidding null, but only sometimes

Section titled “nullable = false: forbidding null, but only sometimes”

nullable = false says a caller may not pass null for this variable — but what happens next depends on whether the variable also has a default. Proven with the identical block, two different tfvars files:

variable "instance_class" {
type = string
nullable = false
default = "db.t3.micro"
}
# terraform.tfvars: instance_class = null
$ terraform plan
Changes to Outputs:
+ instance_class = "db.t3.micro"

With a default present, an explicit null is silently replaced by the default — no error at all. Remove the default and the exact same null becomes a real error instead:

# same variable, no default, terraform.tfvars: instance_class = null
$ terraform plan
Planning failed. Terraform encountered an error while generating this plan.
╷
│ Error: Required variable not set
│
│ on terraform.tfvars line 1:
│ 1: instance_class = null
│
│ The given value is not suitable for var.instance_class defined at
│ main.tf:1,1-26: required variable may not be set to null.
╵

nullable = false alone does not mean “always error on null” — it means “null is never the final value,” and a default is exactly how Terraform resolves a null away instead of rejecting it.

The sensitive = true argument on db_password only affects display. Terraform masks the value in plan/apply output and in the CLI as (sensitive value). It does not encrypt or omit the value from the state file — the state-management module already covered this: state stores every attribute of every managed resource in plain text inside the JSON, including values that came from a sensitive variable. Treat sensitive = true as a display protection for terminals and CI logs, not as a substitute for a properly access-controlled remote backend.

Validating a variable with a validation block

Section titled “Validating a variable with a validation block”

A default or a type constraint only checks the shape of a value, not whether it makes sense. A validation block adds a custom condition and a specific error_message, so a caller gets immediate, actionable feedback at plan time instead of a confusing error from deep inside the AWS provider.

variable "instance_type" {
type = string
description = "EC2 instance type, must be from the approved t3/m6i families"
validation {
condition = can(regex("^(t3|m6i)\\.(micro|small|medium|large|xlarge)$", var.instance_type))
error_message = "instance_type must be a t3 or m6i size, e.g. t3.micro or m6i.large."
}
}
variable "environment" {
type = string
description = "Deployment environment name"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment must be one of: dev, staging, prod."
}
}

Without that first validation block, passing instance_type = "x1.enormous" would sail through terraform plan and only fail once Terraform actually calls the EC2 API to create the instance — a much slower feedback loop, and a much less specific error message than “instance_type must be a t3 or m6i size.” The can() wrapper turns a regex() failure into false instead of an outright error, which is exactly what a condition expression needs: it must always evaluate to true or false, never raise.

Cross-variable validation (Terraform 1.9+)

Section titled “Cross-variable validation (Terraform 1.9+)”

Before 1.9, a validation block’s condition could only reference the variable it was attached to. Terraform 1.9 lifted that restriction: a condition may now reference other variables in the same module, which is what an min/max-shaped pair of inputs actually needs:

variable "min_size" {
type = number
default = 5
}
variable "max_size" {
type = number
validation {
condition = var.max_size >= var.min_size
error_message = "max_size (${var.max_size}) must be >= min_size (${var.min_size})."
}
}

Real failure, max_size = 2 against the min_size default of 5:

$ terraform plan
╷
│ Error: Invalid value for variable
│
│ on terraform.tfvars line 1:
│ 1: max_size = 2
│ ├────────────────
│ │ var.max_size is 2
│ │ var.min_size is 5
│
│ max_size (2) must be >= min_size (5).
│
│ This was checked by the validation rule at variables.tf:8,3-13.
╵

Note the extra context block Terraform prints — var.max_size is 2 / var.min_size is 5 — naming every variable the condition actually referenced. A single-variable validation never prints this, because there is nothing else to attribute the failure to.

Two newer arguments: ephemeral and deprecated

Section titled “Two newer arguments: ephemeral and deprecated”

Two more arguments are worth knowing because they solve problems the ones above cannot.

sensitive = true hides a value from output but still writes it to state. ephemeral = true (Terraform 1.10+) actually keeps it out: the value is available while the operation runs and is omitted from both state and the plan file.

variable "session_token" {
type = string
ephemeral = true
}

The trade-off is that an ephemeral value can only flow into places that also refuse to persist it — a write-only resource argument, another ephemeral variable or resource, a provider block, or a provisioner. Assigning one to an ordinary resource argument is a real, caught-before-apply error, not a lint warning:

variable "session_token" {
type = string
ephemeral = true
default = "demo-token"
}
resource "local_file" "leak" {
filename = "${path.module}/leak.txt"
content = var.session_token
}
$ terraform validate
╷
│ Error: Invalid use of ephemeral value
│
│ with local_file.leak,
│ on main.tf line 15, in resource "local_file" "leak":
│ 15: content = var.session_token
│
│ Ephemeral values are not valid for "content", because it is not a
│ write-only attribute and must be persisted to state.
╵

Terraform catches this statically at validate time — it never even reaches a provider, because the content argument’s schema is not marked write-only, and Terraform already knows every non-write-only argument gets persisted to state. The Secrets and Credential Management lesson shows the matching correct pattern with a write-only argument on the receiving end.

deprecated (Terraform 1.15+) is for module authors renaming an input without breaking every caller at once. The string you supply is shown as a warning whenever someone sets that variable:

variable "subnet_id" {
type = string
default = null
deprecated = "Use subnet_ids instead; subnet_id will be removed in v3.0."
}
variable "subnet_ids" {
type = list(string)
default = []
}

Callers keep working and get told what to change, which is what makes a staged migration possible in a repository where you do not control every consumer of the module.

Terraform lets you set a value for the same variable from several places at once, and it resolves conflicts with a strict, well-defined precedence. From highest priority (wins) to lowest:

  1. -var and -var-file on the command line, in the order given — the most explicit, most temporary way to set a value.
  2. *.auto.tfvars / *.auto.tfvars.json files, auto-loaded from the working directory in lexical (alphabetical) order of filename.
  3. terraform.tfvars / terraform.tfvars.json, auto-loaded if present.
  4. TF_VAR_<name> environment variables — useful for CI pipelines and for values you do not want sitting in a file at all.
  5. The variable’s own default — used only if nothing above supplied a value.
Terminal window
# Highest precedence: an explicit CLI flag for this one run
terraform apply -var="instance_type=m6i.large"
# Or point at a specific tfvars file
terraform apply -var-file="prod.tfvars"
# Environment variable equivalent of setting instance_type
export TF_VAR_instance_type="t3.medium"
terraform apply

If instance_type is set in terraform.tfvars, again in prod.auto.tfvars, and again via -var on the command line, the -var value wins — Terraform does not merge scalar values across sources, it picks the highest-priority one that exists. Collection types like maps and objects are the exception worth remembering later: some tooling merges nested values rather than fully overriding, but for the plain sources above, a defined value at a higher-priority source simply replaces a lower-priority one for that variable.

All five levels, proven in order against one variable "instance_type" { default = "t3.micro" }, adding one higher-priority source at a time:

$ terraform plan # 1. default only
+ instance_type = "t3.micro"
$ terraform plan # 2. terraform.tfvars now present
+ instance_type = "from-terraform-tfvars"
$ terraform plan # 3. zzz.auto.tfvars now also present
+ instance_type = "from-auto-tfvars"
$ TF_VAR_instance_type="from-env-var" terraform plan # 4. env var too -- auto.tfvars still wins
+ instance_type = "from-auto-tfvars"
$ TF_VAR_instance_type="from-env-var" \
terraform plan -var="instance_type=from-cli-var" # 5. -var beats everything
+ instance_type = "from-cli-var"

Step 4 is the one worth pausing on: TF_VAR_* does not outrank an *.auto.tfvars file, even though the env var was set after the file already existed — precedence is about source kind, not about which command you typed last. With no tfvars files present at all, the same TF_VAR_instance_type does beat the default on its own, confirming TF_VAR_* sits above default and below every tfvars-file source.

flowchart LR
  a["-var / -var-file (CLI flag)"] --> r["Resolved value of var.instance_type"]
  b["*.auto.tfvars (alphabetical)"] --> r
  c["terraform.tfvars"] --> r
  d["TF_VAR_instance_type (env var)"] --> r
  e["default in the variable block"] --> r
  r --> res["aws_instance.web"]
Multiple value sources resolve to one value per variable, by precedence

Every input variable resolution is really two separate questions Terraform answers in order: which source wins (the precedence chain, resolved before any HCL in the module runs), and is the winning value acceptable (type, nullable, validation, in that order, all before a provider is ever invoked). ephemeral and write-only arguments add a third axis orthogonal to both: not “what value” or “is it valid,” but “may this value ever touch disk.” Keeping these three questions separate explains every proof above — a cross-variable validation failure names the other variable because the condition genuinely read it; nullable = false with a default doesn’t error because “acceptable value” resolution runs after null-to-default substitution, not before.

ChoiceBenefitCost
validation block (incl. cross-variable, 1.9+)Fails fast at plan time with a specific message (proven above) instead of a confusing provider-level errorEvery rule is checked on every plan; a rule that calls an expensive function (can(regex(...)) on a huge string) adds real, repeated cost
ephemeral = trueValue never touches state or the plan file — genuinely gone after the operation (proven: rejected before it can leak into content)Can only flow into write-only arguments, other ephemeral values, provider blocks, or provisioners — an ordinary resource argument is a hard error, not a fallback
nullable = false + defaultCallers can pass null and quietly get the default instead of juggling coalesce() themselves (proven above)Silent substitution can hide a caller’s mistake — a null that was meant to mean something now means “default”
optional() on object attributesOnly genuinely-required keys are required; every other key gets its own independent default (proven via terraform console)A typo’d optional key name is silently accepted as an extra unknown attribute only if the object type is later widened — the harness/validate still catches unknown attributes today
  • “-var="x=null" clears a string variable to null.” Proven above (nullable proofs): for a string-typed variable, -var treats null as HCL, but a caller who instead writes -var="x=null" inside a shell that has already stripped quoting can end up passing the literal three-character string "null", not the null value — the SpotTheBug below shows the exact difference in real plan output.
  • “nullable = false always rejects null.” Proven above: with a default present, null is silently replaced by the default with no error at all; only a nullable = false variable without a default rejects null, with “required variable may not be set to null.”
  • “sensitive = true keeps a value out of the state file.” It does not — proven in the state-management module: state stores every attribute in plain text regardless of sensitive. Only ephemeral = true (and write-only arguments) actually keep a value out of state, and only for the arguments that accept them.
  • “TF_VAR_* environment variables are checked before file-based tfvars, since env vars feel more ‘live’.” Proven above: TF_VAR_* sits below terraform.tfvars and *.auto.tfvars in precedence, not above.
  • A validation block whose condition references another variable but was written before verifying the target Terraform version supports it (1.9+) — check terraform version, not the model’s assumption.
  • -var used to “unset” a variable to null — proven below this is not guaranteed for a string-typed variable; a real null requires an actual HCL null literal (a -var-file or .tfvars entry), not a quoted shell string.
  • nullable = false added to a variable “to make it required,” without checking whether a default is also present — proven above, the two together silently substitute the default instead of erroring.
  • An object({...}) type with every attribute required, when the surrounding prose says “most fields are optional” — check for missing optional() wrappers.
  • sensitive = true treated as sufficient secret handling — proven elsewhere in this course that state still stores the raw value; only ephemeral/write-only genuinely avoids persistence.
  • A generated ephemeral = true variable assigned straight into an ordinary resource argument (e.g. content, tags, any argument that isn’t write-only) — this is a real terraform validate error, not something that only surfaces at apply.

🐛 Spot the bug

variable "instance_class" {
type = string
nullable = false
default = "db.t3.micro"
}
# CI job "clears" the variable between runs:
# terraform plan -var="instance_class=null"
Show the answer

This does not clear the variable to null — for a string-typed variable, -var="instance_class=null" passes the literal three-character string "null", not the null value. Proven with the exact same block:

$ terraform plan -var="instance_class=null"
+ instance_class = "null"

Compare that to passing a real null through a .tfvars file (instance_class = null, an HCL literal, not a quoted shell string), which correctly falls back to the default:

$ terraform plan # terraform.tfvars: instance_class = null
+ instance_class = "db.t3.micro"

The fix is a real HCL null (a -var-file/.tfvars entry, or -var='instance_class=null' is still the string on the CLI for a string-typed variable — only a non-string type or an explicit null literal in a file is unambiguous), never a bare -var flag when the goal is an actual null.

What does a validation block on a variable actually prevent?
If a variable has a value in terraform.tfvars, in a TF_VAR_ environment variable, and passed via -var on the command line, which value does Terraform use?
What does sensitive = true on a variable actually protect?
Between *.auto.tfvars files and terraform.tfvars, which one wins if both set the same variable?
Which Terraform version introduced the ability for a validation condition to reference a DIFFERENT variable like this?
variable "max_size" {
  type = number
  validation {
    condition     = var.max_size >= var.min_size
    error_message = "max_size must be >= min_size."
  }
}
What happens when you run terraform validate on this configuration?
variable "session_token" {
  type      = string
  ephemeral = true
  default   = "demo-token"
}

resource "local_file" "leak" {
  filename = "out.txt"
  content  = var.session_token
}
A variable is declared with nullable = false and a default value. A caller explicitly passes null for it in a .tfvars file. What happens?