Declaring a Variable a Reviewer Can Understand Without Asking You
A pull request that adds count = 3 directly to an aws_instance block gets one review comment every time: what happens when we need four, and who changes it? The variable block is your answer. Terraform has exactly one mechanism for feeding outside values into a configuration, and the arguments you fill in determine whether that configuration stays reusable, fails fast, or fails three minutes into an apply against a live account.
variable "instance_count" {
type = number
default = 2
description = "Web instances per availability zone."
}
variable "vpc_cidr" {
type = string
description = "CIDR block for the application VPC."
}
variable "db_password" {
type = string
sensitive = true
description = "Master password for the RDS instance."
}
Four arguments carry the weight, and each one answers a different reviewer question:
type— what values are legal.string,number,bool, plus collections (list(string),map(any)) and structured types (object({name = string, port = number})). A type constraint is checked at plan time, before a single provider API call runs.default— whether the variable is optional. Present means Terraform can proceed without a supplied value; absent means the value must come from somewhere or the run stops.description— what the value means. It shows up in the interactive prompt when the variable is required, and documentation generators render it for anyone consuming your module.sensitive— whether the CLI masks the value in plan and apply output. It changes display, not storage; chapter three takes that apart.
The type constraint is the argument people skip and regret. Without it, var.instance_count = "2" is passed through to the provider, which may coerce it or may throw a provider-level error deep in the apply. With type = number, Terraform converts "2" to 2 itself, and "two" fails with an Invalid value for variable message that names your variable and shows the offending value — during terraform plan, while nothing has been created yet. This is the whole philosophy of declarations: push every detectable mistake as early in the lifecycle as possible.
The default is a product decision, not a style choice. A default like default = 2 says "this configuration has a sensible opinion and runs with zero input." No default says "getting this wrong is expensive, so a human must state the value explicitly." For a VPC CIDR or an environment name, requiring the value is correct: a silent default of "dev" pointed at a production account is exactly how accidental production applies happen.
Declarations are also the interface between modules. A reusable network module exposes variable "cidr_block" and a root configuration fills it in, exactly as it fills an argument in a function call — that pattern is the whole reason modules are composable, and it is why an undescribed, untyped variable in a shared module is a support ticket waiting to happen.
Name variables in snake_case and prefix references with var. — var.instance_count, never a bare instance_count. And know where this topic hands off: a declaration alone cannot reject instance_count = -5. Range and format rules belong to Variable Validation Rules, which follows directly from what you declare here. If you are coming from the meta-arguments work in Resource Lifecycle and Meta-Arguments, the shift is simple: count and for_each on resources decide which objects exist, input variables decide what values the whole configuration is built from.
Five Places a Value Can Come From, Checked in a Fixed Order
A variable declared but never supplied is not a value — it is an interactive prompt. Terraform looks for values in several locations, and the order it checks them is a documented precedence ladder. Knowing the ladder explains most "why did it use that value?" incidents.
| Source | Example | Notes |
|---|---|---|
Variable default |
default = 2 |
Lowest precedence; used only when nothing else supplies a value |
| Environment variable | TF_VAR_vpc_cidr=10.0.0.0/16 |
Prefix TF_VAR_ + the variable name |
terraform.tfvars |
plain HCL assignments | Loaded automatically from the working directory |
*.auto.tfvars |
prod.auto.tfvars |
Loaded in lexical order; dev.auto.tfvars before prod.auto.tfvars |
| Command-line flags | -var, -var-file |
Highest precedence; among flags, later ones override earlier |
The same shape exists in JSON: terraform.tfvars.json and *.auto.tfvars.json follow the identical ladder, which matters when a provisioning system emits JSON rather than HCL.
Each source has a place. Day-to-day development uses a checked-in dev.auto.tfvars file so every teammate plans with the same defaults. A one-off override uses a flag:
terraform plan -var 'instance_count=4'
terraform apply -var-file='../../envs/prod/prod.tfvars'
Ephemeral environments and CI use environment variables, because flags leak into shell history and process listings:
export TF_VAR_environment=staging
terraform plan
Here is the trap that catches everyone once: environment variables are always strings. TF_VAR_instance_count=4 arrives as the string "4", and Terraform converts it to 4 only because the declaration says type = number. For structured values you must pass JSON and let the type constraint parse it:
export TF_VAR_tags='{"team":"payments","tier":"gold"}' # type = map(string)
export TF_VAR_backend='{"bucket":"tfstate","region":"eu-west-1"}' # type = object(...)
A raw string assigned to a map(...) variable fails with a type error that looks confusing precisely because the shell never told Terraform it was a map.
When the effective value surprises you, do not read the files again — ask Terraform. From the working directory, terraform console followed by var.instance_count prints the value the configuration will actually use, after every source has been resolved. That one command ends the argument about whether a flag or a file won, and it is much faster than bisecting wrapper scripts.
Two ordering rules cause most disputes. First, if two sources both provide a value, the higher one wins — a -var flag on a script overrides the committed terraform.tfvars file without any warning, which is convenient for demos and dangerous when a wrapper script quietly pins environment = "test" for a production run. Second, among multiple -var-file flags the last one on the command line wins, so teams that concatenate flag lists must keep the order stable.
CI teams usually combine the last two rungs: a committed prod.auto.tfvars for the boring values, environment variables for anything environment-specific, and flags only in the rare one-off override — because flags live in shell history, job logs, and process listings, while an exported variable is scoped to the job. Whatever you choose, print the effective configuration at the start of the pipeline (terraform console output or a grep of the tfvars the script selected) so a failed apply can be answered with evidence rather than memory.
Two hygiene rules round it out. Never commit real secrets in a tfvars file — a db_password in terraform.tfvars is in your git history forever, even after deletion. And avoid the generic terraform.tfvars for environment-specific values: with dev.auto.tfvars, staging.auto.tfvars, and prod.auto.tfvars all present, lexical loading makes the environment a property of the filename, and nobody has to remember which file a script forgot to pass.
Type Constraints, nullable, and What sensitive Actually Hides
The type and sensitive arguments look like metadata. They are enforcement mechanisms, and each one has a boundary worth knowing exactly, because interviews ask about that boundary.
Terraform's type system has three primitive types — string, number, bool — and four collection types — list, set, map, and object. In practice you will write these forms most often:
variable "azs" {
type = list(string) # ["eu-west-1a", "eu-west-1b"]
}
variable "tags" {
type = map(string) # { env = "prod", team = "core" }
}
variable "listener" {
type = object({
port = number
protocol = string
certificate_arn = optional(string)
})
default = { port = 443, protocol = "HTTPS" }
}
Common declarations, in the forms you will actually write:
| Declaration | Accepts |
|---|---|
list(string) |
["a", "b"] — ordered, duplicate-friendly |
set(string) |
unordered, de-duplicated values |
map(string) |
{ env = "prod" } — string keys to string values |
object({a = string, b = number}) |
one object with exactly those fields |
tuple([string, number]) |
fixed-length mixed list; rarely written by hand |
any |
no checking at all — postpone it, do not default to it |
Two features do most of the heavy lifting. Coercion: when a value has the wrong but convertible type, Terraform converts it during evaluation — the string "443" becomes 443 for a number field, and a tuple becomes a list if all elements are compatible. optional() attributes (Terraform 1.3+) let an object type mark fields as skippable, with an optional default: certificate_arn = optional(string) means callers may omit it, and optional(string, "") supplies a default when they do. Pair it with nullable = false (available since 1.1) when you want to reject an explicit null outright — otherwise listener = null satisfies the type but hands null to the provider, which usually fails later with an unhelpful message.
Where a value is unconvertible, plan stops: Invalid value for "instance_count" prints the variable name, the expected type, and the value. That is the point of declaring types — errors surface before any resource exists.
sensitive = true is narrower than most people assume. It does exactly three things:
- Masks the value in
terraform planandapplyCLI output as(sensitive value). - Propagates: anything computed from a sensitive value — a concatenation, a
tolist()— is also masked. - Marks the value in JSON plan output (
sensitive_values) for tooling to respect.
What it does not do is encrypt or omit anything. The value is written into terraform.tfstate in clear text, visible to anyone who can read the state object, and terraform state show <address>-style inspection of the underlying state file reveals it. If your state lives in S3, the real controls are bucket policy, encryption with a customer-managed KMS key, and restricted IAM — not the sensitive flag. For credentials with rotation and audit requirements, the durable pattern is to keep the secret in AWS Secrets Manager or Vault and let Terraform read it via a data source at plan time, so the state stores a reference rather than the secret itself. The display-masking flag and the storage-security boundary are different layers; confusing them is one of the most common interview stumbles on this topic. Understanding what sensitive cannot do is also the bridge to the next phase of work: once you can declare and source values correctly, Variable Validation Rules let you reject bad ones before they ever reach a provider.
Quiz
1. A variable block has no `default` argument. What happens when `terraform plan` runs with no other value source?
2. CI sets `TF_VAR_instance_count=4` and the variable is declared as `type = number`. What value does Terraform use?
3. Both `terraform.tfvars` and a `-var 'instance_count=9'` flag supply `instance_count`. Which wins?
4. You set `sensitive = true` on `db_password`. Which statement is accurate?
Flashcards
Question
What four arguments can a `variable` block take, and what does each control?
Click to reveal answer
Answer
type (which values are legal, checked at plan time), default (whether a value must be supplied; absent = required), description (shown in prompts and module docs), and sensitive (masks the value in plan/apply output only).
Question
In what precedence order does Terraform resolve an input variable's value?
Click to reveal answer
Answer
Variable default (lowest) < TF_VAR_ environment variable < terraform.tfvars / terraform.tfvars.json < *.auto.tfvars in lexical order < -var and -var-file flags (highest, later flags override earlier ones).
Question
How do you pass a map or object value through an environment variable?
Click to reveal answer
Answer
As JSON with the TF_VAR_ prefix, e.g. `TF_VAR_tags='{"team":"core"}'`, and the variable must declare a compatible complex type (`map(string)`, `object({...})`) so Terraform parses the string into that structure. Plain strings only convert when the target type is a primitive or a convertible collection.
Question
What does `sensitive = true` do, and what does it not do?
Click to reveal answer
Answer
It masks the value in plan/apply CLI and JSON output and propagates masking to values derived from it. It does not encrypt, remove, or restrict the value anywhere — the state file still holds it in clear text, so backend access control and encryption are the actual protection.
Question
Why is a type constraint on an input variable considered mandatory in team configurations?
Click to reveal answer
Answer
It moves mistakes to plan time: unconvertible values fail with `Invalid value for variable` naming the variable and value, convertible strings are coerced consistently, and structured types (`object`, `optional()`) define exactly which fields a caller must supply — instead of discovering a bad value inside a provider API call during apply.
Revision Notes
Key Takeaways
- 1. The variable block is Terraform's single input mechanism; type, default, description, and sensitive each answer a different question a reviewer will ask
- 2. A missing default makes the variable required — an interactive prompt for humans and a hard failure in CI
- 3. Values resolve along a fixed precedence ladder with command-line flags on top, so a wrapper script can silently override committed tfvars
- 4. Environment variables are always strings; complex types require JSON on the command line and a matching type constraint to parse
- 5. sensitive masks output but never protects storage — state access control and KMS encryption are the real security boundary
Interview Tips
- • "What is the difference between a variable with a default and one without?" — default = optional with an opinionated fallback; no default = required, prompting interactively and failing in CI; then say why you omit defaults for expensive-to-wrong values like environment names
- • "Where can a Terraform variable get its value from?" — recite the full precedence ladder including TF_VAR_ and *.auto.tfvars lexical ordering; interviewers use this to test whether you have actually debugged a wrong-value incident
- • "Is a sensitive variable safe in state?" — no: sensitive only masks CLI output; the value is plain text in state, so the answer must include backend encryption (S3 + CMK), restricted IAM, or keeping the secret out of state via a data source
- • "How do you pass a map into Terraform in CI?" — TF_VAR_ plus JSON and a declared map/object type; mention that without the type constraint the JSON string is passed through unconverted and the provider fails later
Cheat Sheet
Input Variable Declarations
Anatomy:
variable "listener" {
type = object({ port = number, cert = optional(string) })
default = { port = 443 }
description = "HTTPS listener settings"
sensitive = false # masks in plan output only
nullable = false # reject explicit null (1.1+)
}
Precedence (lowest → highest):
| # | Source |
|---|---|
| 1 | default in the block |
| 2 | TF_VAR_<name> environment variable |
| 3 | terraform.tfvars / .json |
| 4 | *.auto.tfvars / .json, lexical order |
| 5 | -var / -var-file flags, last wins |
Rules of thumb:
- No default ⇒ required; interactive prompt in a shell, failure in CI.
- Env vars are strings — complex values must be JSON + a matching
type. sensitive= display masking; state still stores clear text.- Never commit secrets to tfvars; prefer a secret-manager data source.
- Type errors fail during plan, before any resource is created.