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 module block of the parent.

var.name

Output value

A return value. A child module’s outputs are read by the parent; a root module’s outputs are printed after apply, stored in state and readable by other configurations and automation.

module.child.name (from the parent only)

Local value

A named expression, computed inside the module and visible only there.

local.name

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

type

A type constraint. When omitted the constraint is any, so every value is accepted. Setting a type makes errors clearer for module users and lets Terraform convert compatible values (the string "3" becomes the number 3). Primitive types, collections, structural types and optional() attributes are covered in Types and Expressions.

default

A literal value used when the caller assigns none. It must be convertible to type and it cannot reference other objects of the configuration. A variable with a default is optional; a variable without one is required.

description

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.

validation

A nested block with two required arguments, condition and error_message. It may appear several times in one variable. See Validating input.

sensitive

A boolean, false by default. Terraform redacts the value, and any expression derived from it, in plan and apply output. The value is still written to state, so the state itself must be protected.

nullable

A boolean, true by default. See Nullable variables.

ephemeral

A boolean, false by default (Terraform 1.10). The value is kept out of both the state and the plan files and may only be used in ephemeral contexts. See Ephemeral variables.

deprecated

A string with the reason the variable is deprecated (Terraform 1.15, OpenTofu 1.10). See Deprecating a variable.

const

A boolean, false by default. The value must be known while the configuration is loaded, so the variable can be used in module source and version arguments. See Const variables.

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 }
  }
}

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.

  • condition is any expression that returns a boolean. Use functions such as contains, regex, can, length and alltrue (see Functions). Wrapping a risky expression in can() turns an evaluation error into false.

  • error_message is 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. The format function converts null, lists and maps into text.

  • A variable may have several validation blocks. 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 condition can 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 a precondition on 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 pass null explicitly. When the variable has a default, an explicit null does not fall back to the default: the module receives null. This is how a caller turns an optional feature off.

  • nullable = false guarantees that the module never sees null for that variable. If the caller passes null and a default exists, Terraform substitutes the default; without a default, null is an error. Use it for variables the module code dereferences unconditionally, so the module needs no null checks.

  • 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 ephemeral resource;

  • a provider block;

  • 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:

  1. The default of the variable block.

  2. An environment variable named TF_VAR_ followed by the variable name.

  3. The file terraform.tfvars.

  4. The file terraform.tfvars.json.

  5. Any *.auto.tfvars or *.auto.tfvars.json file in the working directory, processed in lexical filename order.

  6. Any -var and -var-file option 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).

flowchart TB A["default
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

TF_VAR_ environment variable

Silently ignored, because the environment is shared with other tools.

Definition file

A warning. This is why one terraform.tfvars can serve several root modules that declare different subsets.

-var option

An error, because a command-line assignment is always a deliberate act.

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

value

Required. Any expression. Terraform stores the root module’s outputs in the state.

type

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.

description

Explains the purpose of the output and how to use it, from the consumer’s point of view.

sensitive

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 terraform output -json and terraform output -raw print it in clear text.

ephemeral

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.

depends_on

A list of references. Forces Terraform to finish the listed objects before it evaluates the output. Needed rarely; document every use with a comment.

deprecated

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. ignore_nested_deprecations in a module block suppresses the warnings of the modules below it.

precondition

A nested block with condition and error_message. Terraform evaluates it when it creates or applies a plan, before exposing the output or writing it to state, and stops the operation when the condition is false.

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.

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)

ephemeral for input variables and outputs, ephemeralasnull(). Terraform 1.10.2 fixed variables in auto-loaded tfvars files that were shown as changed during apply. OpenTofu added ephemerality in 1.11.

Terraform 1.11

Overriding a variable during apply with TF_VAR_ now yields a warning instead of a misleading error.

Terraform 1.15 (2026-04-29)

deprecated on variable and output, explicit type on output, and variables and locals in module source and version (with const variables). OpenTofu added deprecated in 1.10.

Terraform 1.17 (2026-10-21)

Variables and locals are allowed in provider requirements. terraform show of a local plan reads the variable values stored in the plan file.

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 source and backend

Variables in module source/version since 1.15, which require const = true.

Variables and locals in module sources and backend configuration since 1.8, evaluated statically. const = true (1.12) states the requirement explicitly; when unset OpenTofu infers constness from usage, and const = false forbids use in these places.

Ephemeral variables and outputs

Terraform 1.10.

OpenTofu 1.11.

deprecated

Terraform 1.15.

OpenTofu 1.10. Warnings can be filtered with -deprecation and -consolidation-warnings=false.

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 required_providers

Variables and locals allowed since 1.17.

Not documented on the OpenTofu variables page.

Command names and files

terraform, terraform.tfvars, TF_VAR_.

tofu; the same file names and TF_VAR_ prefix, plus .tofu file variants.

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.tfvars as loaded automatically. That is wrong. The only files Terraform loads on its own are terraform.tfvars, terraform.tfvars.json and files ending in .auto.tfvars or .auto.tfvars.json. A file named tf.tfvars is 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.tfvars included.

  • The book shows export TF_VAR_... for Windows users. export is a POSIX shell command; in PowerShell the variable is set with $Env:TF_VAR_name = "value", as shown above.

  • The book shows an output block with a condition argument. There is no such argument. The supported way to validate an output is a precondition block, as shown above, or a standalone check block when the rule should also run on later plans (see Lifecycle, Validation and Checks). A condition placed 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.tfvars or a committed *.auto.tfvars file. They are read automatically and end up in Git; use TF_VAR_ variables or a secret manager, and mark the variable sensitive or ephemeral.

  • Assuming sensitive = true hides a value from the state. It does not.

  • Relying on the order of -var-file and -var: the last one wins, and a value in an *.auto.tfvars file beats an environment variable. Surprises usually mean an auto-loaded file is overriding what you exported.

  • Passing null to a variable with a default and expecting the default. That works only with nullable = false.

  • Using depends_on on 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.