Variables, Outputs and Expressions
Making configurations flexible: input variables with types and validation, locals, outputs, data sources, count and for_each, and the built-in functions.
Input variables
A configuration with hard-coded values serves one environment. To
use the same files for development, test and production, Ravi Kumar
turns the values that differ into parameters. An
input variable is such a
parameter: declared in a variable block, given a value
from outside when you run OpenTofu, and read inside the
configuration as var.<name>. It works like a
function parameter, with the whole configuration as the function. The
variables.tf of Ravi's second configuration, in
/home/amara/kestrel-infra/vars:
variable "environment" {
description = "Name of the environment."
type = string
default = "dev"
validation {
condition = contains(["dev", "test", "prod"], var.environment)
error_message = "The environment must be one of: dev, test, prod."
}
}
variable "web_count" {
description = "Number of portal web servers."
type = number
default = 2
validation {
condition = var.web_count >= 1 && var.web_count <= 6
error_message = "The web_count must be between 1 and 6."
}
}
variable "services" {
description = "Kestrel services and their ports."
type = map(number)
default = {
portal = 8080
meter-api = 8443
billing = 9100
}
}
variable "db_admin_password" {
description = "Initial password for the PostgreSQL admin on db01."
type = string
default = "example-only"
sensitive = true
}
The arguments a variable block can have:
-
description: what the variable is for. It appears when OpenTofu prompts for a value and in generated documentation; write one for every variable. type: which values are allowed (next section).-
default: the value when none is given. A variable without a default is required. -
validation: extra rules, with an error message. -
sensitive = true: hide the value in plan and apply output. It is still stored in the state; see State: How Terraform Remembers. -
nullable = false: refusenullas a value. Newer versions also haveephemeral = true(OpenTofu 1.11, Terraform 1.10) for values that must never be written to state or plan files.
Types
A type constraint, the
type argument, tells OpenTofu what shape of value the
variable accepts, so a wrong value is rejected with a clear error
before anything is planned. The building blocks:
| Type | Example value | Notes |
|---|---|---|
string |
"prod" |
|
number |
2, 0.5 |
Whole and fractional numbers |
bool |
true |
|
list(string) |
["192.0.2.11", "192.0.2.12"] |
Ordered; duplicates allowed |
set(string) |
["web01", "web02"] |
No order, no duplicates |
map(number) |
{ portal = 8080 } |
Keys are strings; all values one type |
object({ … }) |
{ name = "db01", port = 5432 } |
Named attributes, each with its own type;
optional() marks attributes that may be left
out
|
tuple([…]) |
["db01", 5432] |
Fixed length, a type per position |
any |
No check; avoid it in shared code |
An object type describes a structured setting in one variable:
variable "database" {
description = "Connection settings for the portal database."
type = object({
host = string
port = optional(number, 5432)
name = string
})
default = {
host = "192.0.2.31"
name = "portal"
}
}
Here var.database.port is 5432 because the attribute is
optional with that default. A value of the wrong type fails at plan
time. With web_servers = "192.0.2.11" (a string instead
of a list) in terraform.tfvars of the portal
configuration:
$ tofu plan
…
╷
│ Error: Invalid value for input variable
│
│ on variables.tf line 7:
│ 7: variable "web_servers" {
│
│ The given value is not suitable for var.web_servers declared at
│ variables.tf:7,1-23: list of string required, but have string.
╵
The error points at the declaration, not at the
.tfvars file, because the declaration holds the rule.
And tofu validate did not catch it: validate checks the
configuration files but does not read .tfvars files.
Validation rules
A type cannot say "only dev, test or prod" or "between 1 and 6". A
variable validation rule
can: a validation block with a
condition that must be true and an
error_message for when it is not. A typo in the
environment name is stopped before any resource is touched:
$ tofu plan -var environment=staging
Changes to Outputs:
+ db_admin_password = (sensitive value)
You can apply this plan to save these new output values to the OpenTofu
state, without changing any real infrastructure.
╷
│ Error: Invalid value for variable
│
│ on variables.tf line 1:
│ 1: variable "environment" {
│ ├────────────────
│ │ var.environment is "staging"
│
│ The environment must be one of: dev, test, prod.
│
│ This was checked by the validation rule at variables.tf:6,3-13.
╵
The command exited with status 1. The lines before the error are a
side effect of how OpenTofu works: it checks each rule as soon as
the value is needed, and had already planned the parts that did not
depend on environment. The same happened with
-var web_count=10, where the output first listed four
planned resources under "OpenTofu planned the following actions, but
then encountered a problem" before the error. Look for the error
box; nothing was changed. OpenTofu 1.9 and Terraform 1.9 and later
also allow a condition to refer to other variables, such as "the
production environment needs at least two web servers".
Giving variables a value
There are several ways to set a variable, and when more than one is used, OpenTofu and Terraform apply them in a fixed order in which the later source wins:
$ export TF_VAR_web_count=3
$ tofu plan -var environment=test
$ tofu plan -var-file=prod.tfvars
Two details catch people out. First, a map or object value is
replaced as a whole, not merged: setting services in a
.tfvars file with only portal in it drops
meter-api and billing. Second, the order
is per variable, as this small model shows:
# Where does each variable get its value? Sources from lowest to highest
# precedence, as OpenTofu and Terraform apply them: later sources win.
sources = [
("default in variables.tf", {"environment": "dev", "web_count": 2}),
("TF_VAR_web_count=3", {"web_count": 3}),
("terraform.tfvars", {"environment": "test", "web_count": 4}),
("prod.auto.tfvars", {"environment": "prod"}),
("-var web_count=6", {"web_count": 6}),
]
final = {}
for source, values in sources:
for name, value in values.items():
final[name] = (value, source)
for name, (value, source) in sorted(final.items()):
print(f"{name} = {value!r:8} from {source}")
environment = 'prod' from prod.auto.tfvars web_count = 6 from -var web_count=6
When a required variable has no value from any source, OpenTofu asks
for it, showing the description — handy at a terminal, useless in a
pipeline. With -input=false, which pipelines should
always use, it fails instead:
$ tofu plan -input=false
╷
│ Error: No value for required variable
│
│ on variables.tf line 1:
│ 1: variable "environment" {
│
│ The root module input variable "environment" is not set, and has no default
│ value. Use a -var or -var-file command line argument to provide a value for
│ this variable.
╵
How to organize .tfvars files for dev, test and
production is the subject of
Environments: Dev, Test and Production.
Local values
A local value gives a name to an
expression computed inside the configuration, so it is written once
and used in many places. Locals are declared in a
locals block and read as
local.<name> (singular). Unlike variables, they
cannot be set from outside. The first part of Ravi's
main.tf:
locals {
prefix = "kestrel-${var.environment}"
common_tags = {
owner = "platform-team"
environment = var.environment
}
}
Every name in the configuration now starts with
local.prefix, so a change of naming convention is one
edit. A good rule: a value that people choose is a variable; a value
the configuration derives from them is a local.
count and for_each
Some arguments work on any resource type, because OpenTofu itself
handles them rather than the provider. Such an argument is a
meta-argument:
count, for_each, depends_on,
provider and lifecycle. The first two
create several instances from one block. The rest of
main.tf:
# count: numbered copies
resource "local_file" "web" {
count = var.web_count
filename = "${path.module}/out/${local.prefix}-web${format("%02d", count.index + 1)}.txt"
content = "host=web${format("%02d", count.index + 1)} ip=${cidrhost("192.0.2.0/24", 11 + count.index)}\n"
}
# for_each: one file per named service
resource "local_file" "service" {
for_each = var.services
filename = "${path.module}/out/${local.prefix}-${each.key}.conf"
content = "service=${each.key}\nport=${each.value}\n"
}
resource "local_sensitive_file" "db_admin" {
filename = "${path.module}/out/${local.prefix}-db01.secret"
content = "POSTGRES_PASSWORD=${var.db_admin_password}\n"
}
-
count = 2creates instanceslocal_file.web[0]and[1]; inside the block,count.indexis the number of the current copy, starting at 0. -
for_eachtakes a map or a set of strings and creates one instance per element, addressed by key:local_file.service["portal"]. Inside the block,each.keyis the key andeach.valuethe value.
$ tofu apply -auto-approve
$ tofu state list
…
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Outputs:
db_admin_password = <sensitive>
service_ports = {
"billing" = 9100
"meter-api" = 8443
"portal" = 8080
}
web_files = [
"./out/kestrel-dev-web01.txt",
"./out/kestrel-dev-web02.txt",
]
local_file.service["billing"]
local_file.service["meter-api"]
local_file.service["portal"]
local_file.web[0]
local_file.web[1]
local_sensitive_file.db_admin
Why for_each is usually the better choice
The difference matters the day an item disappears. A test
configuration manages three web server files twice: once with
count over a list, once with for_each over
the same names as a set.
variable "web_hosts" {
type = list(string)
default = ["web01", "web02", "web03"]
}
# Version A: count over a list
resource "local_file" "by_index" {
count = length(var.web_hosts)
filename = "${path.module}/out/count-${var.web_hosts[count.index]}.txt"
content = "${var.web_hosts[count.index]}\n"
}
# Version B: for_each over a set
resource "local_file" "by_name" {
for_each = toset(var.web_hosts)
filename = "${path.module}/out/each-${each.key}.txt"
content = "${each.key}\n"
}
web01 is retired. Remove it from the list and look at
the plan (the attribute lines are left out):
$ tofu plan -var 'web_hosts=["web02","web03"]'
…
# local_file.by_index[0] must be replaced
-/+ resource "local_file" "by_index" {
~ content = <<-EOT # forces replacement
- web01
+ web02
EOT
…
~ filename = "./out/count-web01.txt" -> "./out/count-web02.txt" # forces replacement
…
# local_file.by_index[1] must be replaced
-/+ resource "local_file" "by_index" {
~ content = <<-EOT # forces replacement
- web02
+ web03
EOT
…
# local_file.by_index[2] will be destroyed
# (because index [2] is out of range for count)
…
# local_file.by_name["web01"] will be destroyed
# (because key ["web01"] is not in for_each map)
…
Plan: 2 to add, 0 to change, 4 to destroy.
With count, every element after the removed one moved
up one index: [0] was web01 and must now be web02, so
it is replaced; [1] becomes web03, also replaced; and
[2] is out of range and destroyed. Three actions for
one removal. With for_each, only
["web01"] is destroyed and the others are untouched.
For files this is harmless; for virtual machines, "replaced" means
web02 and web03 are deleted and rebuilt.
That stable key is why for_each is
the right meta-argument for anything that has a name: each instance
is tied to its key, not to its position. Keep count
for truly interchangeable copies, and for switching a resource on or
off:
resource "local_file" "billing_export" {
count = var.environment == "prod" ? 1 : 0
filename = "${path.module}/out/billing-export.cron"
content = "30 2 * * * billing /opt/kestrel/bin/export-billing\n"
}
OpenTofu 1.11 added a clearer way to write the second case, which
Terraform does not have: enabled inside a
lifecycle block. It cannot be combined with
count or for_each, and the resource keeps
a plain address without [0].
resource "local_file" "billing_export" {
filename = "${path.module}/out/billing-export.cron"
content = "30 2 * * * billing /opt/kestrel/bin/export-billing\n"
lifecycle {
enabled = var.environment == "prod"
}
}
Outputs
An output value is the
configuration's return value: something OpenTofu prints after
apply, stores in the state, and makes available to
tofu output, to scripts and to other configurations.
Typical outputs are generated names, addresses and IDs that someone
needs next — the address of the new database server, the URL of the
portal. Ravi's outputs.tf:
output "web_files" {
value = local_file.web[*].filename
}
output "service_ports" {
value = { for name, f in local_file.service : name => var.services[name] }
}
output "db_admin_password" {
value = var.db_admin_password
sensitive = true
}
An output that uses a sensitive value must itself be marked
sensitive = true; OpenTofu refuses otherwise, so that a
secret cannot leak through an output by accident. Scripts should
read outputs as JSON (tofu output -json) or one value
without quotes (tofu output -raw name) rather than
parse the text. Inside a module, outputs are how the module hands
values to its caller — see
Modules and Reuse.
Data sources
Not everything a configuration needs is created by it. The network
was built by another team, the base image is published by the
operating system vendor, the DNS zone already exists. A
data source reads information
about such an existing object without managing it. It is declared
with a data block and read as
data.<type>.<name>. A local example that
reads a file maintained by hand:
data "local_file" "maintenance_page" {
filename = "${path.module}/files/maintenance.html"
}
resource "local_file" "maintenance_copy" {
filename = "${path.module}/out/maintenance.html"
content = data.local_file.maintenance_page.content
}
In the cloud the pattern is the same. This illustrative AWS snippet looks up the production network by its tag and places a new subnet in it, computing the subnet's address range from the network's:
data "aws_vpc" "main" {
tags = {
Name = "kestrel-prod"
}
}
resource "aws_subnet" "portal" {
vpc_id = data.aws_vpc.main.id
cidr_block = cidrsubnet(data.aws_vpc.main.cidr_block, 8, 3)
}
Data sources are read during plan, so the plan works
with current information; when a data source depends on something
not yet created, it is read during apply instead and
the plan shows it with the <= symbol. A data source
that finds nothing, or more than one match where one is expected, is
usually an error — which is what you want: better a failed plan than
a subnet in the wrong network.
Expressions
Anywhere a value is expected you can write an expression. The ones you will use most:
| Expression | Example | Result |
|---|---|---|
| References |
var.environment, local.prefix,
random_id.deployment.hex
|
A value from elsewhere in the configuration |
| String template | "kestrel-${var.environment}" |
"kestrel-dev" |
| Arithmetic and comparison |
var.web_count >= 1 && var.web_count <=
6
|
true |
| Conditional | var.environment == "prod" ? 3 : 1 |
1 in dev |
for expression (list) |
[for h in ["web01", "web02"] :
"${h}.kestrel.example"]
|
A list of two names |
for expression (map, filtered) |
{ for name, port in var.services : name => port if
port > 9000 }
|
{ billing = 9100 } |
| Splat | local_file.web[*].filename |
The attribute of every instance, as a list |
for expressions are the
comprehensions of HCL.
For repeating a nested block inside a resource, such as one firewall
rule block per port, there is a dynamic block; use it
sparingly, because it makes plans harder to read.
Built-in functions and tofu console
Outside the experimental symbol libraries of OpenTofu 1.13, HCL has
no way to define your own functions, but it ships with many built-in
ones: string functions (format,
join, split, upper),
collection functions (length, keys,
merge, lookup, toset),
network functions (cidrhost, cidrsubnet),
file functions (file, templatefile),
encoding functions (jsonencode,
yamlencode, base64encode) and
error-handling helpers (try, coalesce).
Providers can add functions of their own, called as
provider::<name>::<function>()
(OpenTofu 1.7, Terraform 1.8). OpenTofu 1.13 added
convert() for converting a value to a given type.
The quickest way to learn them is tofu console (or
terraform console): an interactive prompt that
evaluates expressions against the current configuration and state,
without changing anything. In the vars folder after the
apply above:
$ tofu console
…
> local.prefix
"kestrel-dev"
> var.services
tomap({
"billing" = 9100
"meter-api" = 8443
"portal" = 8080
})
> var.db_admin_password
(sensitive value)
> cidrsubnet("10.20.0.0/16", 8, 3)
"10.20.3.0/24"
> [for i in range(3) : cidrsubnet("10.20.0.0/16", 8, i)]
[
"10.20.0.0/24",
"10.20.1.0/24",
"10.20.2.0/24",
]
> cidrhost("192.0.2.0/24", 11)
"192.0.2.11"
> format("web%02d.kestrel.example", 7)
"web07.kestrel.example"
…
> lookup(var.services, "ledger", 0)
0
> merge(local.common_tags, { service = "meter-api" })
{
"environment" = "dev"
"owner" = "platform-team"
"service" = "meter-api"
}
> { for name, port in var.services : name => "${name}:${port}" if port > 9000 }
{
"billing" = "billing:9100"
}
…
> toset(["web01", "web02", "web01"])
toset([
"web01",
"web02",
])
> local_file.service["portal"].filename
"./out/kestrel-dev-portal.conf"
…
> try(var.services["ledger"], 9999)
9999
> var.web_count + "two"
╷
│ Error: Invalid operand
│
│ on <console-input> line 1:
│ (source code not available)
│
│ Unsuitable value for right operand: a number is required.
╵
The console shows types too: tomap(…),
tolist(…) and toset(…) (note that the set
dropped the duplicate). Sensitive values stay hidden. Type
exit or press Ctrl+D to leave.
cidrsubnet(prefix, newbits, netnum) deserves a closer
look, because subnet plans are built with it: it lengthens the
prefix by newbits bits and returns subnet number
netnum. A /16 plus 8 bits gives /24 subnets, and number
3 is 10.20.3.0/24. The same arithmetic with Python's
ipaddress module (see
prefix notation):
import ipaddress
def cidrsubnet(prefix, newbits, netnum):
"""Like OpenTofu's cidrsubnet(): subnet number netnum, newbits longer."""
network = ipaddress.ip_network(prefix)
subnets = network.subnets(prefixlen_diff=newbits)
for number, subnet in enumerate(subnets):
if number == netnum:
return str(subnet)
def cidrhost(prefix, hostnum):
"""Like OpenTofu's cidrhost(): address number hostnum in the prefix."""
return str(ipaddress.ip_network(prefix).network_address + hostnum)
print(cidrsubnet("10.20.0.0/16", 8, 3))
print([cidrsubnet("10.20.0.0/16", 8, i) for i in range(3)])
print([cidrhost("192.0.2.0/24", 11 + i) for i in range(2)])
10.20.3.0/24 ['10.20.0.0/24', '10.20.1.0/24', '10.20.2.0/24'] ['192.0.2.11', '192.0.2.12']
The last line matches the files that count created:
web01 got 192.0.2.11 and
web02 got 192.0.2.12.
Errors you will meet
tofu validate catches most mistakes in expressions
before a plan is even attempted, and its messages are precise: file,
line, the offending code and often a suggestion. All four of these
were recorded on the portal configuration, each with exit status 1.
A misspelled argument name:
╷ │ Error: Unsupported argument │ │ on main.tf line 17, in resource "local_file" "upstream": │ 17: file_permisson = "0644" │ │ An argument named "file_permisson" is not expected here. Did you mean │ "file_permission"? ╵
A reference to a variable that was never declared:
╷ │ Error: Reference to undeclared input variable │ │ on main.tf line 13, in resource "local_file" "upstream": │ 13: environment = var.enviroment │ │ An input variable with the name "enviroment" has not been declared. Did you │ mean "environment"? ╵
An attribute the resource does not have:
╷ │ Error: Unsupported attribute │ │ on main.tf line 15, in resource "local_file" "upstream": │ 15: deployment = random_id.deployment.hexadecimal │ │ This object has no argument, nested block, or exported attribute named │ "hexadecimal". ╵
A value of the wrong type for a resource argument:
╷ │ Error: Incorrect attribute value type │ │ on main.tf line 2, in resource "random_id" "deployment": │ 2: byte_length = "four" │ │ Inappropriate value for attribute "byte_length": a number is required. ╵
For an unknown attribute, the provider's documentation lists every
attribute a resource exports; tofu state show on an
existing resource shows them with their values.
Troubleshooting Infrastructure as Code
covers errors that come from providers and platforms rather than
from the language.