Input Variables
The idea in one sentence
Section titled “The idea in one sentence”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.
Declaring a variable block
Section titled “Declaring a variable block”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 planPlanning 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 planChanges 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 planPlanning 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.
Setting variable values, and who wins
Section titled “Setting variable values, and who wins”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:
-varand-var-fileon the command line, in the order given — the most explicit, most temporary way to set a value.*.auto.tfvars/*.auto.tfvars.jsonfiles, auto-loaded from the working directory in lexical (alphabetical) order of filename.terraform.tfvars/terraform.tfvars.json, auto-loaded if present.TF_VAR_<name>environment variables — useful for CI pipelines and for values you do not want sitting in a file at all.- The variable’s own
default— used only if nothing above supplied a value.
# Highest precedence: an explicit CLI flag for this one runterraform apply -var="zone=europe-west1-b"
# Or point at a specific tfvars fileterraform apply -var-file="prod.tfvars"
# Environment variable equivalent of setting zoneexport TF_VAR_zone="us-east1-b"terraform applyIf 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"]
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
validation block (incl. cross-variable, 1.9+) | Fails fast at plan time with a specific message (proven above) instead of a confusing provider-level error | Every rule is checked on every plan; a rule that calls an expensive function (can(regex(...)) on a huge string) adds real, repeated cost |
ephemeral = true | Value 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 + default | Callers 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 attributes | Only 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 |
Common Misconceptions
Section titled “Common Misconceptions”- “
-var="x=null"clears a string variable to null.” Proven above (nullable proofs): for astring-typed variable,-vartreatsnullas 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 thenullvalue — the SpotTheBug below shows the exact difference in real plan output. - “
nullable = falsealways rejectsnull.” Proven above: with adefaultpresent,nullis silently replaced by the default with no error at all; only anullable = falsevariable without a default rejectsnull, with “required variable may not be set to null.” - “
sensitive = truekeeps a value out of the state file.” It does not — proven in the state-management module: state stores every attribute in plain text regardless ofsensitive. Onlyephemeral = 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 belowterraform.tfvarsand*.auto.tfvarsin precedence, not above.
Reading AI-generated code
Section titled “Reading AI-generated code”- A
validationblock whoseconditionreferences another variable but was written before verifying the target Terraform version supports it (1.9+) — checkterraform version, not the model’s assumption. -varused to “unset” a variable tonull— proven below this is not guaranteed for astring-typed variable; a realnullrequires an actual HCLnullliteral (a-var-fileor.tfvarsentry), not a quoted shell string.nullable = falseadded to a variable “to make it required,” without checking whether adefaultis 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 missingoptional()wrappers. sensitive = truetreated as sufficient secret handling — proven elsewhere in this course that state still stores the raw value; onlyephemeral/write-only genuinely avoids persistence.- A generated
ephemeral = truevariable assigned straight into an ordinary resource argument (e.g.content,labels, any argument that isn’t write-only) — this is a realterraform validateerror, not something that only surfaces at apply.
🐛 Spot the bug
variable "zone" {type = stringnullable = falsedefault = "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.