Variables, Outputs and Locals
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A Terraform module is a function in disguise. Input variables are its parameters, output values are its return
values and local values are the temporary names it uses in between. Every root module and every child module has
exactly these three kinds of named values, and they are the way a configuration is parameterized, reused and wired to
other configurations. This page describes them as documented for Terraform 1.17 and OpenTofu 1.13: every argument of
the variable, output and locals blocks, the complete order in which a variable receives its value, how outputs
cross module boundaries and reach the command line, and what changed in recent releases. It follows
HCL Syntax and Configuration Files in the reading
path and prepares Types and Expressions, which covers the type
system used by the type argument in depth, and Modules, where variables and
outputs become a module’s interface.
All examples use the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket).
They run locally with the kreuzwerker/docker and hashicorp/random providers, so no cloud account is needed.
The three kinds of values
| Kind | Role | Reference syntax |
|---|---|---|
Input variable |
A parameter of the module. Its value comes from outside the module: the command line, a file, the environment, an
HCP Terraform workspace, or the |
|
Output value |
A return value. A child module’s outputs are read by the parent; a root module’s outputs are printed after |
|
Local value |
A named expression, computed inside the module and visible only there. |
|
Two rules explain most of the design. First, a module never reads another module’s variables or locals: values cross
a boundary only as an argument in a module block (down) or as an output (up). Second, input variables are the only
values a user of the configuration can change without editing it, which is why the rest of this page spends most of
its time on how their values are chosen.
Input variables
A variable is declared with a variable block with one label, its name. Names must be valid identifiers and must be
unique within the module. A handful of names are reserved because they are meta-arguments of the module block:
source, version, providers, count, for_each, lifecycle, depends_on and locals.
terraform {
required_version = ">= 1.15"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
}
}
variable "environment" {
type = string
description = "Deployment environment of the Bookshelf stack."
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "The environment must be one of dev, staging or prod."
}
}
variable "catalog_api_replicas" {
type = number
description = "Number of catalog-api containers to start."
default = 1
}
variable "database_password" {
type = string
description = "Password of the PostgreSQL superuser."
sensitive = true
}
Inside the module a variable is read as var.environment. Terraform evaluates most variables when it builds a plan.
The exceptions are the const variables described below, which are evaluated while the configuration is being
loaded.
The arguments of a variable block
| Argument | Meaning |
|---|---|
|
A type constraint. When omitted the constraint is |
|
A literal value used when the caller assigns none. It must be convertible to |
|
A string documenting the variable, written from the point of view of the consumer of the module. Maintainer notes belong in comments. The text feeds the prompt shown when a value is missing and the documentation generated by tools such as terraform-docs. |
|
A nested block with two required arguments, |
|
A boolean, |
|
A boolean, |
|
A boolean, |
|
A string with the reason the variable is deprecated (Terraform 1.15, OpenTofu 1.10). See Deprecating a variable. |
|
A boolean, |
Types and defaults in practice
Prefer the most specific type that expresses the contract. A list(string) rejects a number, an object with named
attributes documents the shape of a settings bundle, and optional() lets callers omit attributes that have a
fallback.
variable "services" {
type = map(object({
image = string
replicas = optional(number, 1)
port = number
}))
description = "Bookshelf services, keyed by name."
default = {
catalog-api = { image = "bookshelf/catalog-api:1.4.0", port = 8080 }
reviews-api = { image = "bookshelf/reviews-api:1.4.0", port = 8081, replicas = 2 }
web = { image = "bookshelf/web:1.4.0", port = 8000 }
}
}
Source: Terraform Docs — Type Constraints.
A default cannot call resources or other variables. When a value must be derived from another variable, compute it in a local value instead.
Validating input
A validation block turns a rule about the value into an early, readable error. Terraform evaluates the rules while
it creates a plan; when a condition is false the operation stops and the error_message is shown.
-
conditionis any expression that returns a boolean. Use functions such ascontains,regex,can,lengthandalltrue(see Functions). Wrapping a risky expression incan()turns an evaluation error intofalse. -
error_messageis mandatory and may be any expression that returns a string: a literal, a heredoc or a template. Write it as one or more full sentences, as Terraform’s own errors are. Theformatfunction convertsnull, lists and maps into text. -
A variable may have several
validationblocks. Each is evaluated and each failing rule reports its own message, which keeps rules small and messages specific. -
Since Terraform 1.9 (and OpenTofu 1.9) a
conditioncan refer to other objects of the module, such as other input variables and data sources. Before that release a rule could only mention the variable it validated, and cross-field rules had to be enforced with apreconditionon a resource.
variable "catalog_api_replicas" {
type = number
default = 1
validation {
condition = var.catalog_api_replicas >= 1 && var.catalog_api_replicas == floor(var.catalog_api_replicas)
error_message = "catalog_api_replicas must be a whole number greater than or equal to 1."
}
# Cross-variable rule (Terraform 1.9 and later): production needs redundancy.
validation {
condition = var.environment != "prod" || var.catalog_api_replicas >= 2
error_message = "In the prod environment catalog_api_replicas must be at least 2."
}
}
variable "covers_bucket_name" {
type = string
description = "Name of the bucket that stores book covers."
validation {
condition = can(regex("^[a-z0-9][a-z0-9-]{2,61}[a-z0-9]$", var.covers_bucket_name))
error_message = "The bucket name must be 3 to 63 characters of lowercase letters, digits or hyphens."
}
}
Variable validation is for facts about input. Rules that need the result of a resource, the shape of a data source
or a check that must keep running after the apply belong to precondition, postcondition and check blocks, covered
in Lifecycle, Validation and Checks.
Nullable variables
nullable decides whether the caller may pass null. The defaults are subtle, because null normally means "use the
default":
-
nullable = true(the default) lets a caller passnullexplicitly. When the variable has adefault, an explicitnulldoes not fall back to the default: the module receivesnull. This is how a caller turns an optional feature off. -
nullable = falseguarantees that the module never seesnullfor that variable. If the caller passesnulland adefaultexists, Terraform substitutes the default; without a default,nullis an error. Use it for variables the module code dereferences unconditionally, so the module needs nonullchecks. -
Nulls nested inside a collection or object are not affected: only the outermost value is checked.
variable "redis_image" {
type = string
default = "redis:7"
nullable = false # a caller passing null gets redis:7
}
variable "extra_labels" {
type = map(string)
default = {}
# nullable defaults to true: null means "no labels were supplied at all".
}
Sensitive variables
sensitive = true is a display control. Terraform replaces the value with (sensitive value) in plan and apply
output, treats every expression that uses it as sensitive, and refuses to show it in terraform output unless
explicitly asked. It does not encrypt anything: the value, and every resource argument derived from it, is stored in
the state in clear text and may still leak through provider error messages or resource IDs. Protect the state itself
(see State and Backends and
Remote State) and prefer ephemeral values when the secret does not have to be remembered. The wider picture
(sensitive values, ephemeral resources, write-only arguments) is in
Sensitive Data, Ephemeral Values and Write-Only
Arguments.
If a sensitive value feeds an output, that output must also be marked sensitive; otherwise Terraform stops with an
error rather than printing the secret by accident.
Ephemeral variables
ephemeral = true (Terraform 1.10, OpenTofu 1.11) goes further than sensitive: the value is never written to the
state or the plan file. It exists only in memory for the duration of one Terraform phase, so it is the right choice for
credentials and tokens that a provider needs but that should never be stored. Because the value disappears, Terraform
limits where it can be used. An ephemeral variable, or any expression that references one, can only appear in:
-
the argument of another ephemeral variable or of an ephemeral output (child modules only, see Output values);
-
a write-only argument of a managed resource;
-
the configuration of an
ephemeralresource; -
a
providerblock; -
a provisioner or provisioner connection block.
Using an ephemeral value anywhere else, for example in a normal resource argument, is a plan-time error. The function
ephemeralasnull() returns a copy of a value with its ephemeral parts replaced by null, which helps when a
value must be passed to a context that does not accept ephemeral input. Variables can be both sensitive and
ephemeral (the official example does both for an access key secret), so the value is also masked in logs.
variable "docker_registry_password" {
type = string
description = "Password used to pull private Bookshelf images."
ephemeral = true
sensitive = true
}
provider "docker" {
registry_auth {
address = "registry.example.com"
username = "bookshelf-ci"
password = var.docker_registry_password # allowed: a provider block is an ephemeral context
}
}
Deprecating a variable
deprecated (Terraform 1.15, OpenTofu 1.10) lets a module author retire a variable without breaking callers. The
argument takes the message to show. Terraform prints a warning when a root module sets the variable, or when a
module call passes a value for it, so consumers learn about the rename while their configuration keeps working.
Deprecate first, then remove the variable in the next major version of the module.
variable "db_pass" {
type = string
sensitive = true
deprecated = "db_pass is deprecated, use database_password instead."
default = null
}
Const variables
Normally Terraform evaluates variables during planning, which is too late for arguments the CLI needs while it is
still loading the configuration. Since Terraform 1.15 the source and version arguments of a module block can
use variables and locals, and variables used there must be declared const = true (the CLI also accepts variable
values on most commands to support this). A const variable must have a value that does not depend on any resource or
data source, and Terraform rejects a const variable that is also sensitive or ephemeral. Terraform 1.17 extends the
idea to the required_providers block, which can now use variables and locals too. Use it sparingly: it makes the
module graph depend on input values, which tools that analyse static source (registries, scanners) cannot follow.
variable "network_module_version" {
type = string
default = "1.4.0"
const = true
}
module "network" {
source = "app.terraform.io/example-org/network/docker"
version = var.network_module_version
}
Assigning values to variables
A root module’s variables can be set in many ways, and several of them can apply at once. Terraform collects every
source and keeps, for each variable, the value from the source with the highest precedence. A child module is
different: it has exactly one source, the arguments of the module block that calls it (plus defaults).
Precedence
From lowest to highest, a root-module variable gets its value from:
-
The
defaultof the variable block. -
An environment variable named
TF_VAR_followed by the variable name. -
The file
terraform.tfvars. -
The file
terraform.tfvars.json. -
Any
*.auto.tfvarsor*.auto.tfvars.jsonfile in the working directory, processed in lexical filename order. -
Any
-varand-var-fileoption on the command line, in the order given. HCP Terraform workspace variables are applied at this level too.
A later source overrides an earlier one, and a value cannot be assigned twice within the same file. If no source
provides a value and the variable has no default, Terraform prompts for it in interactive mode and fails with an
error when input is disabled (-input=false or TF_INPUT=0).
in the variable block"] --> B["TF_VAR_name
environment variable"] B --> C["terraform.tfvars"] C --> D["terraform.tfvars.json"] D --> E["*.auto.tfvars and *.auto.tfvars.json
lexical order"] E --> F["-var and -var-file
command-line order,
HCP Terraform workspace variables"] F --> G(("value used
by var.name")) style A fill:#eeeeee,stroke:#888888,color:#000000 style F fill:#cfe8ff,stroke:#2b6cb0,color:#000000
Each arrow means "is overridden by the next box when that source also sets the variable". The diagram reads from the weakest source at the top to the strongest at the bottom; in practice this yields a simple rule of thumb: put safe defaults in the code, per-environment values in files, and one-off overrides on the command line.
Variable definition files
A variable definition file has the extension .tfvars (or .tfvars.json) and consists of plain assignments, with no
variable blocks:
# terraform.tfvars
environment = "staging"
catalog_api_replicas = 2
covers_bucket_name = "bookshelf-covers-staging"
A JSON definition file is a single object whose keys are variable names:
{
"environment": "staging",
"catalog_api_replicas": 2
}
Terraform loads these files automatically from the working directory: terraform.tfvars, terraform.tfvars.json
and every file whose name ends in .auto.tfvars or .auto.tfvars.json. Any other name, including a file called
staging.tfvars, is ignored unless you pass it with -var-file:
terraform plan -var-file="environments/staging.tfvars"
terraform apply -var-file="environments/staging.tfvars" -var="catalog_api_replicas=3"
The -var-file and -var options are processed in the order they appear, so later options win. The usual pattern for
several environments is a directory of .tfvars files that are selected explicitly in CI; see
Workspaces, Environments and Project
Structure.
Command line and environment
-var sets a single variable and can be repeated. Quote complex values for your shell, and prefer a definition file
for anything beyond strings and numbers:
terraform apply -var="environment=prod" -var='extra_labels={"team":"catalog"}'
Environment variables are the preferred channel for secrets in automation, because they never touch the repository or
the shell history. The variable name is appended to TF_VAR_ exactly, including its case. Complex values use the
same literal syntax as a definition file (HCL or JSON, following the rules of your shell):
export TF_VAR_database_password="$(op read 'op://bookshelf/postgres/password')" # from a secret manager, never typed
export TF_VAR_services='{"web":{"image":"bookshelf/web:1.4.1","port":8000}}'
terraform plan
export is POSIX shell syntax. In PowerShell, on Windows or elsewhere, the same variable is set for the current session
through the Env: drive:
$Env:TF_VAR_environment = "dev"
terraform plan
Values that do not match a declaration
Terraform handles an assignment for a variable that the module does not declare differently by source:
| Source | Behavior |
|---|---|
|
Silently ignored, because the environment is shared with other tools. |
Definition file |
A warning. This is why one |
|
An error, because a command-line assignment is always a deliberate act. |
Source: Terraform Docs — Input variables.
Passing variables to child modules
Inside a configuration, a parent assigns a child’s variables with arguments in the module block. Required variables
must be assigned; optional ones fall back to their defaults.
module "database" {
source = "./modules/postgres"
password = var.database_password # the parent's variable
database = "catalog"
replicas = local.is_production ? 2 : 1
}
Output values
Output values are the return values of a module. In the root module they are printed at the end of terraform
apply, stored in the state and available to other configurations and to automation; in a child module they are the
only way to hand a value to the parent.
output "catalog_api_url" {
type = string
description = "Base URL that clients use to reach catalog-api."
value = "http://localhost:${docker_container.catalog_api.ports[0].external}"
}
output "database_password" {
description = "Generated PostgreSQL password."
value = random_password.postgres.result
sensitive = true
}
The arguments of an output block
| Argument | Meaning |
|---|---|
|
Required. Any expression. Terraform stores the root module’s outputs in the state. |
|
Optional type constraint (Terraform 1.15). Without it an output accepts any type. Adding types to module outputs makes the module’s interface explicit and improves matching between validation, plan and apply. |
|
Explains the purpose of the output and how to use it, from the consumer’s point of view. |
|
A boolean. Redacts the value in the plan and apply output, and in HCP Terraform’s UI and API. The value remains in
the state, and |
|
A boolean (Terraform 1.10). Keeps the value out of state and plan. Allowed only in child modules: a root module’s output block cannot set it. The value must come from an ephemeral context (an ephemeral resource, an ephemeral variable, another module’s ephemeral output or a write-only argument), and the output can only be consumed in those same contexts. |
|
A list of references. Forces Terraform to finish the listed objects before it evaluates the output. Needed rarely; document every use with a comment. |
|
A string (Terraform 1.15, OpenTofu 1.10). Child modules only. Callers that reference the output see a warning; the
module that defines the output does not. |
|
A nested block with |
Outputs that guard themselves
An output precondition is the supported way to assert something about the value a module returns. It is useful for
modules whose consumers depend on a guarantee that cannot be expressed as a variable rule.
output "covers_bucket_url" {
description = "URL of the covers bucket."
value = module.covers.url
precondition {
condition = startswith(module.covers.url, "https://")
error_message = "The covers bucket must be served over HTTPS."
}
}
output "instance_ip" {
value = docker_container.catalog_api.network_data[0].ip_address
# The address is only usable once the firewall sidecar is running.
depends_on = [docker_container.firewall]
}
Reading outputs
After an apply Terraform prints the root outputs. Later, terraform output reads them from the state without
contacting any provider:
$ terraform output
catalog_api_url = "http://localhost:18080"
database_password = <sensitive>
$ terraform output -raw catalog_api_url
http://localhost:18080
$ terraform output -json | jq -r '.catalog_api_url.value'
http://localhost:18080
-raw prints a string, number or bool without quotes, which makes it safe in shell substitution; -json prints every
output as JSON for scripts (see Machine-Readable
Output and Integrations). Only the root module’s outputs are shown; a child module’s outputs are visible only through
the parent. Outputs are not printed by terraform plan; a plan only mentions that an output will change. Other
configurations can consume a root output with the terraform_remote_state data source, covered in
Backends and Remote State, and in HCP Terraform the workspace
overview lists them.
Source: Terraform Docs — terraform output.
Module outputs
A child module’s outputs are read in the parent as module.<name>.<output>. When the module call uses count or
for_each, the reference is indexed like any other instance. A module output is also the standard way to build a
dependency: a resource that reads module.database.connection_string waits for everything the output depends on.
# modules/postgres/outputs.tf
output "connection_string" {
description = "libpq connection string for the catalog database."
value = "postgres://catalog:${random_password.postgres.result}@${docker_container.postgres.name}:5432/catalog"
sensitive = true
}
# main.tf in the root module
resource "docker_container" "catalog_api" {
name = "catalog-api"
image = docker_image.catalog_api.image_id
env = ["DATABASE_URL=${module.database.connection_string}"]
}
The module-level view (file layout, versioning, publishing) is in Modules and Module Development and Publishing.
Local values
A locals block gives a name to an expression so that it is written once and reused. Locals behave like function-scope
variables: they are visible only in the module that declares them, are read as local.name (singular, though the block
is plural), and are computed from variables, resource attributes, function results and other locals. A module may
contain any number of locals blocks, which Terraform merges; splitting them is only for organization. Each block
must define at least one value, and local names must be valid identifiers. Locals may refer to one another in any order
as long as there is no cycle.
locals {
is_production = var.environment == "prod"
name_prefix = "bookshelf-${var.environment}"
common_labels = {
project = "bookshelf"
environment = var.environment
managed_by = "terraform"
}
}
locals {
replicas = local.is_production ? max(var.catalog_api_replicas, 2) : var.catalog_api_replicas
service_names = [for name, svc in var.services : "${local.name_prefix}-${name}"]
}
resource "docker_container" "catalog_api" {
count = local.replicas
name = "${local.name_prefix}-catalog-api-${count.index}"
image = docker_image.catalog_api.image_id
dynamic "labels" {
for_each = local.common_labels
content {
label = labels.key
value = labels.value
}
}
}
Use a local when a value is reused in several places, when it comes from a complicated expression that deserves a name,
or when a variable needs a derived companion that its own default cannot compute. Avoid overuse: a local hides where
a value originates, so a module full of single-use locals is harder to read than the plain expression. A local is not
a configuration knob. If someone should be able to change the value without editing code, make it a variable. When a
local derives from a sensitive variable it is sensitive too, and from an ephemeral one it is ephemeral.
Since Terraform 1.15 locals (like const variables) may be used in the source and version of a module block, and
since Terraform 1.17 in required_providers. OpenTofu has allowed variables and locals in module sources and backend
configurations since 1.8, with the limitation that they must be computable without state.
What changed recently
| Version | Change |
|---|---|
Terraform 1.9 (2024-06-26) |
Input variable validation rules can refer to other variables and to data sources of the module. OpenTofu followed in 1.9. |
Terraform 1.10 (2024-11-27) |
|
Terraform 1.11 |
Overriding a variable during |
Terraform 1.15 (2026-04-29) |
|
Terraform 1.17 (2026-10-21) |
Variables and locals are allowed in provider requirements. |
Terraform and OpenTofu differences
The two tools share this part of the language, and the examples above work unchanged with tofu. The differences
that matter are about timing and a few arguments.
| Topic | Terraform | OpenTofu |
|---|---|---|
Variables in module |
Variables in module |
Variables and locals in module sources and backend configuration since 1.8, evaluated statically. |
Ephemeral variables and outputs |
Terraform 1.10. |
OpenTofu 1.11. |
|
Terraform 1.15. |
OpenTofu 1.10. Warnings can be filtered with |
Cross-variable validation |
Terraform 1.9. |
OpenTofu 1.9. |
Map and object overrides |
A higher-precedence value replaces the value from a lower source. There is no merge. |
The same: a map or object value is replaced, not merged. The documentation flags this as a change from earlier releases. |
Providers in |
Variables and locals allowed since 1.17. |
Not documented on the OpenTofu variables page. |
Command names and files |
|
|
Where the books are outdated or wrong
Three statements in one of the reference books for this section no longer hold or never did, and they are worth correcting because they cause real surprises:
-
The book describes a file called
tf.tfvarsas loaded automatically. That is wrong. The only files Terraform loads on its own areterraform.tfvars,terraform.tfvars.jsonand files ending in.auto.tfvarsor.auto.tfvars.json. A file namedtf.tfvarsis silently ignored, so the values in it never apply unless it is passed with-var-file. The same holds for any other custom name,prod.tfvarsincluded. -
The book shows
export TF_VAR_...for Windows users.exportis a POSIX shell command; in PowerShell the variable is set with$Env:TF_VAR_name = "value", as shown above. -
The book shows an
outputblock with aconditionargument. There is no such argument. The supported way to validate an output is apreconditionblock, as shown above, or a standalonecheckblock when the rule should also run on later plans (see Lifecycle, Validation and Checks). Aconditionplaced directly in an output block is rejected with an unsupported argument error.
Statements in the book about validation reflect the older rule that a check may mention only the validated variable,
whereas current releases accept references to other variables, and nothing there covers ephemeral, deprecated or
const. On any discrepancy follow the official documentation for the version you run.
Pitfalls
-
Putting secrets into
terraform.tfvarsor a committed*.auto.tfvarsfile. They are read automatically and end up in Git; useTF_VAR_variables or a secret manager, and mark the variablesensitiveorephemeral. -
Assuming
sensitive = truehides a value from the state. It does not. -
Relying on the order of
-var-fileand-var: the last one wins, and a value in an*.auto.tfvarsfile beats an environment variable. Surprises usually mean an auto-loaded file is overriding what you exported. -
Passing
nullto a variable with a default and expecting the default. That works only withnullable = false. -
Using
depends_onon outputs to paper over a missing reference. Prefer a real data dependency. -
Reading a child module output that does not exist: the error appears at plan time as an unsupported attribute.