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 Compute Engine instances to launch"
default = 2
}
variable "enable_deletion_protection" {
type = bool
description = "Whether to enable deletion protection on the Compute Engine instance"
default = true
}
variable "allowed_ports" {
type = list(number)
description = "TCP ports allowed inbound on the firewall rule"
default = [22, 443]
}
variable "resource_labels" {
type = map(string)
description = "Labels applied to every resource in this configuration"
default = {
project = "payments"
team = "platform"
}
}
variable "db_config" {
type = object({
database_version = string
tier = string
disk_size_gb = number
})
description = "Structured configuration for the Cloud SQL instance"
default = {
database_version = "POSTGRES_15"
tier = "db-custom-1-3840"
disk_size_gb = 20
}
}
variable "db_password" {
type = string
description = "Password for the Cloud SQL application user"
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 GCP 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 database_version 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({
database_version = string
tier = optional(string, "db-custom-1-3840")
disk_size_gb = optional(number, 20)
availability_type = optional(string, "ZONAL")
})
default = {
database_version = "POSTGRES_15"
}
}

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

$ echo 'var.db_config' | terraform console
{
"availability_type" = "ZONAL"
"database_version" = "POSTGRES_15"
"disk_size_gb" = 20
"tier" = "db-custom-1-3840"
}

This is different from the plain object({...}) shown above: without optional(), a caller who omits tier gets a hard “attribute is required” error at plan time. With it, the object is genuinely partially-required — only database_version 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 Google 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 "zone" {
type = string
nullable = false
default = "us-central1-a"
}
# terraform.tfvars: zone = null
$ terraform plan
Changes to Outputs:
+ zone = "us-central1-a"

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: zone = null
$ terraform plan
Planning failed. Terraform encountered an error while generating this plan.
╷
│ Error: Required variable not set
│
│ on terraform.tfvars line 1:
│ 1: zone = null
│
│ The given value is not suitable for var.zone 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 Google provider.

variable "zone" {
type = string
description = "GCP zone the resources are deployed into, e.g. us-central1-a"
validation {
condition = can(regex("^[a-z]+-[a-z0-9]+-[a-z]$", var.zone))
error_message = "zone must look like a GCP zone, e.g. us-central1-a or europe-west1-b."
}
}
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 zone = "not-a-real-zone" would sail through terraform plan and only fail once Terraform actually calls the Google provider to create a resource in that zone — a much slower feedback loop, and a much less specific error message than “zone must look like a GCP zone.” 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_instances" {
type = number
default = 5
}
variable "max_instances" {
type = number
validation {
condition = var.max_instances >= var.min_instances
error_message = "max_instances (${var.max_instances}) must be >= min_instances (${var.min_instances})."
}
}

Real failure, max_instances = 2 against the min_instances default of 5:

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

Note the extra context block Terraform prints — var.max_instances is 2 / var.min_instances 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.

Ephemeral values flowing into a place that must persist

Section titled “Ephemeral values flowing into a place that must persist”

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

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="zone=europe-west1-b"
# Or point at a specific tfvars file
terraform apply -var-file="prod.tfvars"
# Environment variable equivalent of setting zone
export TF_VAR_zone="us-east1-b"
terraform apply

If zone 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 "zone" { default = "us-central1-a" }, adding one higher-priority source at a time:

$ terraform plan # 1. default only
+ zone = "us-central1-a"
$ terraform plan # 2. terraform.tfvars now present
+ zone = "from-terraform-tfvars"
$ terraform plan # 3. zzz.auto.tfvars now also present
+ zone = "from-auto-tfvars"
$ TF_VAR_zone="from-env-var" terraform plan # 4. env var too -- auto.tfvars still wins
+ zone = "from-auto-tfvars"
$ TF_VAR_zone="from-env-var" \
terraform plan -var="zone=from-cli-var" # 5. -var beats everything
+ zone = "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_zone 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.zone"]
  b["*.auto.tfvars (alphabetical)"] --> r
  c["terraform.tfvars"] --> r
  d["TF_VAR_zone (env var)"] --> r
  e["default in the variable block"] --> r
  r --> res["google_compute_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 the Google 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, labels, 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 "zone" {
type = string
nullable = false
default = "us-central1-a"
}
# CI job "clears" the variable between runs:
# terraform plan -var="zone=null"
Show the answer

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

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

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

$ terraform plan # terraform.tfvars: zone = null
+ zone = "us-central1-a"

The fix is a real HCL null (a -var-file/.tfvars entry — 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_instances" {
  type = number
  validation {
    condition     = var.max_instances >= var.min_instances
    error_message = "max_instances must be >= min_instances."
  }
}
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?