skip to content

Variables & Outputs

How configuration flows in and results flow out: typed variables with validation, the precedence order across tfvars, env and CLI flags, outputs, and sensitive marking. Interviewers ask about precedence because it explains why the value you set is not the value applied.

part ofTerraformoverview, primer and where to startread it →
on this pageshow

questions

24

In Terraform, what does the `type` argument in a `variable` block do, and what categories of type constraint can you declare?

level: juniorimportance: must knowfreq 72%

answer

  1. three families of constraint
  2. primitive, collection, structural
  3. conversion first, then rejection
  4. checked while variables are evaluated
  5. omitting type means any

basics

~20 s

Terraform's type argument constrains what a caller may pass, rejecting mismatches during plan before anything is created. Type constraints come in three families: primitives (string, number, bool), collections (list, set, map), and structural types (object, tuple).

solid answer

~50 s

The `type` argument on a `variable` block is a type constraint — a declaration of the shape of value the module accepts. There are three families. Primitives are `string`, `number` and `bool`. Collections — `list(T)`, `set(T)`, `map(T)` — hold any number of elements that must all share one element type. Structural types — `object({name = string, size = number})` and `tuple([string, number])` — describe a fixed shape where each attribute or position has its own type. There is also `any`, a wildcard, which is what you get if you omit `type` entirely. Terraform checks the supplied value against the constraint while evaluating variables at the start of a plan, converting where the conversion is unambiguous (the string `"5"` to the number `5`, a `["a","b"]` literal to `list(string)`) and failing the whole run where it is not. The payoff is that a bad input is caught in seconds rather than halfway through an apply.

code

hcl · 19 lines
hcl
variable "region" {
  type = string
}

variable "azs" {
  type = list(string)
}

variable "tags" {
  type = map(string)
}

variable "database" {
  type = object({
    engine   = string
    size_gb  = number
    multi_az = bool
  })
}

go deeper

for a junior

Be ready to name the three families and write a variable block with type, description and default. Say plainly that a wrong-shaped value fails the plan rather than the apply.

for a middle

Explain the conversion step: a bracketed literal is a tuple that Terraform converts to a list or set, and "5" converts to a number. Be able to nest a structural type inside a collection and justify it.

for a senior

Show why a strict interface is operationally cheap: a mis-shaped input caught during variable evaluation costs a terminal message, while the same mistake reaching apply leaves a partially built estate to unwind.

for a principal

Own the interface-stability angle: a module's type constraints are its contract with every consumer, so tightening one is a breaking change that belongs behind a version bump and a documented migration.

## What a type constraint actually is A `variable` block declares one input to a Terraform module — the root module included. Its `type` argument is not a runtime check bolted on the side; it is part of the module's published interface, and Terraform uses it to *convert* as well as to *reject*. ```hcl variable "instance_name" { type = string description = "Name tag applied to the instance" } ``` When Terraform evaluates the root module's variables at the beginning of a plan, it takes the value supplied for `instance_name`, attempts to convert it to `string`, and either succeeds (the module now sees a `string`) or aborts the entire run with an "Invalid value for input variable" error. For a child module, the same thing happens to each argument written in the calling `module` block. Nothing is created, nothing is refreshed against the API — the failure lands before the graph walk begins. ## The three families **Primitive types** are `string`, `number` and `bool`. Terraform has exactly one number type; there is no separate integer. **Collection types** — `list(T)`, `set(T)` and `map(T)` — hold zero or more elements that must all have the same type `T`. `list(string)` is ordered and indexed by position, `set(string)` is unordered and de-duplicated, `map(string)` is keyed by arbitrary strings. **Structural types** — `object({...})` and `tuple([...])` — describe a fixed shape whose members may each have a different type: ```hcl variable "database" { type = object({ engine = string size_gb = number multi_az = bool }) } ``` A caller passes `{ engine = "postgres", size_gb = 100, multi_az = true }`. Each attribute is checked against its own declared type. `tuple([string, number])` is the positional equivalent and is rare in hand-written code — it mostly shows up as the type Terraform infers for a mixed literal like `["a", 1]`. These nest freely: `list(object({name = string, port = number}))` is an ordinary and very useful constraint for something like a list of listener rules. ## Conversion comes before rejection Terraform is not strictly typed in the "exact match or die" sense. It applies automatic conversions where there is only one sensible reading: `"5"` converts to the number `5` for a `number` variable, `true` converts to `"true"` for a `string`, and a bracketed literal — which is really a *tuple* — converts to `list(string)` or `set(string)` when its elements can all become strings. A braced literal is an *object* and converts to `map(...)` on the same terms. Conversions that would lose information or are ambiguous, such as `"abc"` to `number`, fail. This is why a `.tfvars` file full of quoted values usually works against numeric variables: the conversion, not the author, did the work. ## Omitting the type If you leave `type` off, the variable's constraint is `any` and every value is accepted as-is. That is legal, and it is what a lot of older module code does, but it means the module has no declared interface: a caller learns the expected shape only by reading the module body or by watching the plan fail somewhere deep inside a resource argument. ## Why this matters more in infrastructure code than elsewhere Infrastructure code has an unusually expensive failure mode: a run that dies in the middle has already created some real resources and left the rest undone. Type constraints are the cheapest possible test in that world — they run in the first second of `terraform plan`, cost nothing, and need no credentials or test harness. Declaring `number` on a retention period and `object({...})` on a subnet definition converts a class of caller mistakes from a partially-applied estate into a message on the terminal. The practical rule for module authors: declare the tightest type that is honestly true. `string` beats `any`; `list(object({...}))` beats `list(any)`; and where the shape is genuinely rich but partly optional, the object type with optional attributes is the modern way to say so.

  • Where in the Terraform run does a type mismatch surface, and why does that timing matter?
    While Terraform evaluates variables at the start of the plan — for the root module from the supplied values, for a child module from the arguments in its `module` block. The run aborts before the graph is walked, so no resource has been created or changed. That is the difference between a terminal error and a half-applied estate you now have to reconcile.
  • What is the difference between a tuple and a list in Terraform, and when do you actually meet a tuple?
    A `list(T)` holds any number of elements that all share one type; a `tuple([...])` has a fixed length with a declared type per position. You rarely declare tuples, but you meet them constantly: every bracketed literal in HCL is a tuple, which Terraform then converts to the list or set the constraint asks for. A mixed literal like `["a", 1]` stays a tuple because no single element type fits.
  • Is a type constraint enough to guarantee a variable's value is usable?
    No. A constraint describes shape, not meaning: `number` accepts `-1` for a retention period, and `string` accepts `"eu-nowhere-1"` as a region. Types stop mis-shaped input; they say nothing about whether a well-shaped value is sensible. That is a separate concern from the type system.

saying these in an interview costs you the question

  • Thinking type constraints are checked only at apply time
  • Claiming Terraform has separate int and float types
  • Believing collections can hold mixed element types
  • Assuming omitting type makes the variable required
  • Treating object and map as interchangeable

context

open as a page

In Terraform, what is a locals block, and how does it differ from an input variable and from an output?

level: juniorimportance: must knowfreq 72%

basics

~20 s

A Terraform locals block names a computed value for reuse inside one module. Unlike an input variable it cannot be set from outside — no tfvars, CLI flag or environment override — and unlike an output it exposes nothing to the module's caller.

open as a page

In Terraform, what does an `output` block do, and how does a calling configuration read an output declared in a child module?

level: juniorimportance: must knowfreq 76%

basics

~20 s

An output block publishes a value out of the module that declares it. Root module outputs are printed after apply and readable with terraform output; a child module's outputs are read by its caller as module.<name>.<output_name>.

open as a page

In Terraform, what does a `validation` block inside a `variable` block do, and which two arguments does it take?

level: juniorimportance: must knowfreq 66%

basics

~20 s

A validation block makes Terraform reject an unacceptable input value at plan time. It takes condition, a boolean expression over the variable, and error_message, the text printed when the condition is false — so nothing gets created.

open as a page

A Terraform variable can be typed `list(string)`, `set(string)` or `map(string)`. How do the three differ, and what does the module receive if a `set(string)` variable is given the value `["b", "a", "a"]`?

level: middleimportance: must knowfreq 62%

basics

~20 s

A list is ordered and allows duplicates, a set is unordered and de-duplicated, a map is keyed by strings. Given ["b", "a", "a"], a set(string) variable yields two elements — "a" and "b" — with the caller's ordering and the duplicate discarded.

open as a page

In Terraform, the same input variable is set by the variable block's default, by terraform.tfvars, by an *.auto.tfvars file, by a TF_VAR_ environment variable and by -var on the command line. Which value does the run use, and what is the full precedence order?

level: middleimportance: must knowfreq 72%

basics

~10 s

The command line wins. From lowest to highest, Terraform applies the variable block default, TF_VAR_ environment variables, terraform.tfvars, terraform.tfvars.json, *.auto.tfvars files in lexical order, then -var and -var-file options in the order given.

open as a page

In Terraform, what does marking an `output` block with `sensitive = true` actually hide, and what does it not protect?

level: middleimportance: must knowfreq 68%

basics

~20 s

It redacts the value from Terraform's own display — plan, apply, and terraform output show (sensitive value) — and marks anything derived from it sensitive too. It is not encryption: the value is stored in plain text in state and printed by terraform output -json.

open as a page

What rules about a Terraform input variable can a `validation` block enforce that a `type` constraint cannot?

level: middleimportance: must knowfreq 55%

basics

~20 s

A type constraint checks the shape of a value — string, number, an object with named attributes. A validation block checks the value itself: allowed set, numeric range, naming pattern, collection length, and cross-field rules that no type can express.

open as a page

In Terraform, which variable-definition files does the CLI load automatically, and how do you supply values from a file it does not auto-load, such as prod.tfvars?

level: juniorimportance: should knowfreq 62%

basics

~10 s

Terraform automatically loads terraform.tfvars, terraform.tfvars.json, and any file ending in .auto.tfvars or .auto.tfvars.json from the directory it runs in. Any other name, such as prod.tfvars, is read only when you pass -var-file=prod.tfvars.

open as a page

In Terraform 1.3 or later, how do you declare a module input that takes an object where some attributes may be omitted by the caller, and what value do the omitted attributes get?

level: middleimportance: should knowfreq 46%

basics

~10 s

Wrap the attribute's type in optional() inside the object type constraint. An omitted attribute declared optional(string) becomes null; optional(string, "t3.micro") supplies that default instead. Required attributes stay unwrapped and must be given.

open as a page

Your Terraform root module must stamp the same owner, environment and cost-centre tags on every AWS resource, plus a per-resource Name tag. How do you structure that with locals, and what happens on the next plan when someone adds a tag?

level: middleimportance: should knowfreq 62%

basics

~20 s

Define the shared tags once in a locals block as a map, then set each resource's tags to merge(local.common_tags, { Name = "..." }) so per-resource keys win. Adding a key is a one-line edit that produces an in-place tag update across every resource using the local.

open as a page

In Terraform, when are locals evaluated, and what shows up in the plan when a local's expression reads an attribute of a resource that does not exist yet?

level: middleimportance: should knowfreq 44%

basics

~20 s

Terraform evaluates locals as nodes in the dependency graph, not top to bottom, so declaration order does not matter. A local that reads an attribute of an uncreated resource is unknown during plan, and every value derived from it renders as (known after apply).

open as a page

In Terraform, what happens when a root-module variable has no default and no value is supplied — first in an interactive terminal, and then in a CI job running terraform apply -input=false?

level: middleimportance: should knowfreq 46%

basics

~20 s

Interactively, Terraform stops and prompts on the terminal for each unset variable that has no default, then continues with what you type. With -input=false there is no prompt: the run fails with an error saying there is no value for a required variable.

open as a page

A Terraform plan fails with "Output refers to sensitive values" on an output that only builds a connection string. What causes this, and what are the two legitimate ways to resolve it?

level: middleimportance: should knowfreq 42%

basics

~20 s

Sensitivity is contagious in Terraform: a sensitive value interpolated into any expression makes the whole result sensitive, and Terraform refuses to export that silently. Resolve it by marking the output sensitive = true, or by wrapping a genuinely non-revealing derived value in nonsensitive().

open as a page

Why do Terraform validation conditions so often wrap an expression in `can()`, and what goes wrong if the condition expression itself raises an error?

level: middleimportance: should knowfreq 40%

basics

~20 s

A condition must evaluate to true or false, but functions like regex(), cidrsubnet() and tonumber() raise an error on malformed input instead of returning false. can() runs the expression and yields false if it errored, so the caller sees your error_message rather than a raw function failure.

open as a page

A Terraform module declares `variable "tags" { type = map(any) }` and a caller passes `{ Name = "web", Retention = 30 }`. What does the module actually receive, and why is `map(any)` a poor interface?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Terraform unifies a collection's element type to one concrete type, so the module receives a map of strings where Retention is "30". If no single element type fits — one value being a list, say — the whole plan fails instead. map(any) neither preserves mixed types nor checks anything.

open as a page

A Terraform CI job exports TF_VAR_app_version before running terraform apply, but every apply keeps deploying the version pinned in a committed app.auto.tfvars. Why is the environment variable losing, and what do you change?

level: seniorimportance: should knowfreq 42%

basics

~20 s

TF_VAR_ environment variables sit near the bottom of Terraform's precedence order, above only the variable block default, so any auto-loaded *.auto.tfvars file overrides them. Remove the pin from the committed file, or inject the value with -var instead.

open as a page

A Terraform module generates a database password with `random_password` and exposes it as an output so operators can retrieve it. Trace where that value ends up, and say what marking the output sensitive does and does not change.

level: seniorimportance: should knowfreq 46%

basics

~20 s

The value lands in the plan and apply logs, in state, and in anything reading terraform output. Marking the output sensitive removes it from Terraform's own display only; state still holds it in plain text and terraform output -json still prints it. Prefer writing it to a secret store and exporting only its identifier.

open as a page

When would you reach for a `precondition` or `postcondition` in Terraform instead of a variable `validation` block?

level: seniorimportance: should knowfreq 44%

basics

~20 s

A validation block checks the module's own input. Preconditions and postconditions check assumptions about everything else: a precondition guards an object before it is created, and a postcondition asserts a guarantee about what was actually produced.

open as a page

What does `nullable = false` do on a Terraform input variable, and how does it change what happens when a caller explicitly passes `null` to a variable that has a default?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

nullable = false forbids the value being null inside the module. With the default nullable = true, an explicit null overrides the default and the module sees null; with nullable = false, Terraform substitutes the default instead, or errors if there is none.

open as a page

In Terraform, how do `terraform output -json` and `terraform output -raw NAME` differ from plain `terraform output`, and how does each treat values marked sensitive?

level: middleimportance: nice to knowfreq 34%

basics

~20 s

Plain terraform output prints a human-readable list and shows sensitive values as <sensitive>. The -json flag emits machine-readable JSON of every output, and -raw NAME prints one value with no quotes or formatting. Both -json and -raw print sensitive values in clear text.

open as a page

You inherit a Terraform module where nearly every resource argument is a reference to a local, some chained three deep through other locals. What does that cost the team, and when is a local genuinely earning its place?

level: seniorimportance: nice to knowfreq 32%

basics

~20 s

Every layer of indirection is another jump a reviewer must make to see the real value, and a widely-shared local silently fans one edit out across many resources. A local earns its place when it removes genuine repetition or names a complex expression, not when it merely renames a variable.

open as a page

Terraform 1.5 added top-level `check` blocks. How do they differ from a `postcondition`, and when would you choose one over the other?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

A failed postcondition is an error that stops the run; a failed assertion in a check block is a warning that does not. Check blocks are for non-blocking verification of things outside the module's control, so an unrelated problem cannot wedge the pipeline.

open as a page

Across a Terraform estate with many root modules, how do you decide which variable values are committed as tfvars files and which are injected at run time, and what goes wrong when a laptop and CI disagree?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Commit everything that should be reviewable and identical for everyone — sizes, CIDRs, environment names — and inject only secrets and genuinely per-run identifiers. Values that live in one engineer's shell make plans differ by machine, with no error to warn anyone.

open as a page