Terraform Variables Explained - Input Variables (string, number, bool, list, map, object), Validation, Sensitive, Locals, Outputs and tfvars
Terraform variables are a way to store values that you can reuse throughout your Terraform configuration. You define a value once, reference it in multiple places, and change it in one place when the environment changes - t3.micro in dev, m5.large in prod, same code.
I wrote the first version of this post in 2021 for Terraform 0.14. Since then Terraform has added quite a lot to variables - validation blocks, sensitive and ephemeral variables, optional object attributes - and the precedence rules between terraform.tfvars, -var-file and environment variables confuse people every single week in my YouTube comments. So this is the full picture as of Terraform 1.16, with working AWS examples for every type.
- Variables are declared in a
variableblock, where you give a name, a type and usually a default. Here is how they look inside a Terraform project -

- Terraform variables can be of type string, number, bool, list, set, map, object, tuple or
any. - Values can be set on the command line with
-var, from a file with-var-file, fromterraform.tfvarsautomatically, or fromTF_VAR_environment variables. - You read a variable with
var.<name>, for examplevar.instance_type. - Variables are useful for everything that changes between environments or between people running the code.
Table of Content
- Types of Terraform Variables
- Terraform Variables - string, number, bool
- Terraform Variables - list, set, map
- Terraform Variables - object and tuple (structural types)
- How to assign values - -var, -var-file, terraform.tfvars, auto.tfvars, TF_VAR_ and their precedence
- Variable validation, sensitive, nullable and ephemeral
- Terraform Locals
- Terraform Output Variables
- Common errors with Terraform variables
- Conclusion
Pre-requisite
Before we start working with Terraform variables, here are the pre-requisites -
- You must install terraform (click here on how to install terraform)
- You must have either an AWS or Google Cloud account (here are the AWS EC2 and Google Cloud VM setup tutorials)
All the examples use this provider setup. Notice there are no access keys in the code - Terraform picks them up from aws configure, environment variables or an IAM role, which I have explained in how to handle AWS credentials in Terraform -
1terraform {
2 required_version = ">= 1.5"
3 required_providers {
4 aws = {
5 source = "hashicorp/aws"
6 version = "~> 6.0"
7 }
8 }
9}
10
11provider "aws" {
12 region = var.region
13}
14
15variable "region" {
16 description = "AWS region to deploy into"
17 type = string
18 default = "eu-central-1"
19}
1. Types of Terraform Variables
Terraform has three families of variable types -
- Primitive types -
string,number,bool - Collection types -
list(...),set(...),map(...)- many values of the same type - Structural types -
object({...}),tuple([...])- many values of different types with a fixed shape
Plus any, which means "whatever is passed in". Always give a variable an explicit type - it is the cheapest form of validation you will ever write, and it makes terraform plan fail early with a clear message instead of deep inside a provider.
2. Terraform Variables - string, number, bool
Let's take a simple example in which we are going to set up an EC2 instance on AWS. Here is the main.tf which we are going to parameterize step by step -
1resource "aws_instance" "ec2_example" {
2 ami = data.aws_ami.ubuntu.id
3 instance_type = "t3.micro"
4
5 tags = {
6 Name = "Terraform EC2"
7 }
8}
9
10# latest Ubuntu 24.04 AMI, instead of a hard-coded ami id that only works in one region
11data "aws_ami" "ubuntu" {
12 most_recent = true
13 owners = ["099720109477"] # Canonical
14 filter {
15 name = "name"
16 values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
17 }
18}
2.1 string variable type - We are going to parameterize instance_type = "t3.micro"
The first rule to create a parameter in a terraform file is to define a variable block -
1variable "instance_type" {
2 description = "EC2 instance type"
3 type = string
4 default = "t3.micro"
5}
For defining a variable block you need
- description : a short description of the purpose of the variable - it is shown when Terraform prompts for a value and in the module documentation
- type : what type the variable is -
string,bool,number... - default : the default value. Without a
default, the variable is required and Terraform prompts for it (or fails in CI with-input=false)
Let's replace the hardcoded value of instance_type with the variable
1 instance_type = var.instance_type
Here is our final terraform file after replacing the hardcoded value with the variable -
1resource "aws_instance" "ec2_example" {
2 ami = data.aws_ami.ubuntu.id
3 instance_type = var.instance_type
4
5 tags = {
6 Name = "Terraform EC2"
7 }
8}
9
10variable "instance_type" {
11 description = "EC2 instance type"
12 type = string
13 default = "t3.micro"
14}
And now you can apply your terraform configuration, overriding the default on the command line if you want -
1terraform apply
2terraform apply -var="instance_type=t3.small"
By convention the variable blocks live in a separate variables.tf file - Terraform reads all .tf files in the directory, so it makes no difference to the result, only to readability. I have explained the file layout in how to split main.tf into multiple files.
2.2 number variable type - We are going to parameterize instance_count = 2
The next variable type is number. For example, we are going to control how many EC2 instances to create -
1variable "instance_count" {
2 description = "How many EC2 instances to create"
3 type = number
4 default = 2
5}
Here is the terraform file using it with count -
1resource "aws_instance" "ec2_example" {
2 count = var.instance_count
3
4 ami = data.aws_ami.ubuntu.id
5 instance_type = "t3.micro"
6
7 tags = {
8 Name = "Terraform EC2 ${count.index + 1}"
9 }
10}
11
12variable "instance_count" {
13 description = "How many EC2 instances to create"
14 type = number
15 default = 2
16}
Numbers can be integers or decimals (0.5), and Terraform converts between "2" and 2 automatically where the type is clear. The count meta-argument and its cousin for_each have their own post - Terraform count, for_each and for loops.
2.3 boolean variable type - We are going to parameterize associate_public_ip_address
The bool variable holds true or false. It is the standard way to build an on/off switch into your configuration -
1variable "enable_public_ip" {
2 description = "Give the instance a public IP address"
3 type = bool
4 default = true
5}
Let's use it in the EC2 instance -
1resource "aws_instance" "ec2_example" {
2 ami = data.aws_ami.ubuntu.id
3 instance_type = "t3.micro"
4 associate_public_ip_address = var.enable_public_ip
5
6 tags = {
7 Name = "Terraform EC2"
8 }
9}
10
11variable "enable_public_ip" {
12 description = "Give the instance a public IP address"
13 type = bool
14 default = true
15}
A bool is also how you create a resource conditionally - count = var.create_bastion ? 1 : 0. Pass it on the command line as -var="enable_public_ip=false" (no quotes around the value inside).
3. Terraform Variables - list, set, map
When it comes to collection variables then we are talking about -
- List - ordered, allows duplicates, accessed by index
- Set - unordered, no duplicates, the natural input for
for_each - Map - key/value pairs, accessed by key
3.1 List variable type
As the name suggests we are going to define a list that contains more than one element. Here is a list of IAM user names -
1variable "user_names" {
2 description = "IAM usernames"
3 type = list(string)
4 default = ["user1", "user2", "user3"]
5}
And here is how you use it - by index with count, or by looking up one element -
1resource "aws_iam_user" "example" {
2 count = length(var.user_names)
3 name = var.user_names[count.index]
4}
5
6output "first_user" {
7 value = var.user_names[0] # "user1"
8}
9
10variable "user_names" {
11 description = "IAM usernames"
12 type = list(string)
13 default = ["user1", "user2", "user3"]
14}
Tip - for a list of names like this one,
for_each = toset(var.user_names)is better thancount, because removinguser2from the middle of the list would otherwise rename and recreateuser3. The reason is explained with a diagram in count vs for_each.
3.2 Set variable type
A set(string) looks like a list but has no order and no duplicates. It is what for_each wants -
1variable "user_names" {
2 description = "IAM usernames"
3 type = set(string)
4 default = ["user1", "user2", "user3"]
5}
6
7resource "aws_iam_user" "example" {
8 for_each = var.user_names
9 name = each.value
10}
You cannot index a set (var.user_names[0] is an error) - convert it with tolist() if you need positions.
3.3 Map variable type
Terraform also supports the map variable type where you define key = value pairs, all values of the same type. The classic use is tags -
1variable "project_environment" {
2 description = "Project name and environment"
3 type = map(string)
4 default = {
5 project = "project-alpha"
6 environment = "dev"
7 }
8}
Use the whole map, or look up one key -
1resource "aws_instance" "ec2_example" {
2 ami = data.aws_ami.ubuntu.id
3 instance_type = "t3.micro"
4
5 tags = var.project_environment
6}
7
8output "env" {
9 value = var.project_environment["environment"] # "dev"
10}
A very common pattern is a map of maps keyed by environment, looked up with the lookup function or directly -
1variable "instance_type_by_env" {
2 type = map(string)
3 default = {
4 dev = "t3.micro"
5 prod = "m5.large"
6 }
7}
8
9# instance_type = var.instance_type_by_env[var.environment]
4. Terraform Variables - object and tuple (structural types)
When one "thing" has several attributes of different types, use an object. This is what you want for anything more complex than a flat string, and it replaces the old habit of passing five separate variables -
1variable "server" {
2 description = "Settings of the web server"
3 type = object({
4 name = string
5 instance_type = string
6 disk_gb = number
7 monitoring = optional(bool, false) # optional attribute with a default (Terraform 1.3+)
8 extra_tags = optional(map(string), {})
9 })
10
11 default = {
12 name = "web-01"
13 instance_type = "t3.micro"
14 disk_gb = 20
15 }
16}
17
18resource "aws_instance" "web" {
19 ami = data.aws_ami.ubuntu.id
20 instance_type = var.server.instance_type
21 monitoring = var.server.monitoring
22
23 root_block_device {
24 volume_size = var.server.disk_gb
25 }
26
27 tags = merge({ Name = var.server.name }, var.server.extra_tags)
28}
optional() means the caller can leave monitoring and extra_tags out and they get the defaults - without it, every attribute of an object is required. A list of objects (list(object({...}))) is the usual input for anything repeated, like a list of ingress rules, and pairs naturally with for_each or a dynamic block.
A tuple is a fixed-length list with a type per position - tuple([string, number, bool]). I rarely use it in variables; an object with named attributes is always clearer.
5. How to assign values - -var, -var-file, terraform.tfvars, auto.tfvars, TF_VAR_ and their precedence
A variable with a default works out of the box. For everything else, and for overriding defaults, Terraform gives you five ways to assign values, and the order of precedence matters -
From lowest to highest, exactly as the Terraform documentation lists it -
- The
defaultin the variable block. TF_VAR_<name>environment variables -export TF_VAR_instance_type=t3.small. Great for CI and for secrets, so they never land in a file.terraform.tfvarsorterraform.tfvars.json- loaded automatically if the file exists in the working directory. You do not pass it with-var-file.*.auto.tfvars/*.auto.tfvars.json- also loaded automatically, in alphabetical order of file name.-var-file=<file>on the command line - for files with any other name (prod.tfvars), in the order given.-var="name=value"on the command line, and variables set in HCP Terraform - the highest. "Values defined in HCP Terraform and on the command line take precedence over other ways of assigning variable values."
Here is an example from one of my projects with a terraform.tfvars file -

What a tfvars file contains - only assignments, name = value, one per line, in HCL syntax -
1# terraform.tfvars
2project_id = "gcp-terraform-307119"
3location = "europe-central2"
4instance_type = "t3.micro"
5user_names = ["alice", "bob"]
6tags = {
7 team = "platform"
8}
The matching declarations still go in variables.tf - a tfvars file can only assign variables that are declared -

1# variables.tf
2variable "project_id" {
3 type = string
4 description = "The Project ID"
5}
6
7variable "location" {
8 description = "Region for the project"
9 type = string
10 default = "europe-central2"
11}
I have a whole post on the difference between the two files - variables.tf vs terraform.tfvars.
Using a named file per environment -
1terraform plan -var-file=dev.tfvars
2terraform apply -var-file=prod.tfvars
3
4# several files - later files override earlier ones
5terraform apply -var-file=common.tfvars -var-file=prod.tfvars
Note -
terraform initdoes not take-var-file(the first version of this post said it did - wrong). Variables are only needed forplan,apply,destroy,import,refreshandconsole. If your backend configuration needs values, that is-backend-config, a different thing.
Passing complex values on the command line - use HCL syntax inside the quotes -
1terraform apply -var='user_names=["alice","bob"]' -var='tags={team="platform"}'
2export TF_VAR_user_names='["alice","bob"]'
Undeclared variables - Terraform warns if a .tfvars file assigns a variable that is not declared, and errors if you do it with -var. The fix is always to add the variable block. I have written up the exact message in value for undeclared variable.
6. Variable validation, sensitive, nullable and ephemeral
Four arguments on the variable block that did not exist when I first wrote this post, and that I now use in every module -
validation - check the value at plan time and fail with your own message. The condition must reference the variable, and error_message is shown when it is false -
1variable "environment" {
2 description = "Deployment environment"
3 type = string
4
5 validation {
6 condition = contains(["dev", "staging", "prod"], var.environment)
7 error_message = "environment must be one of: dev, staging, prod."
8 }
9}
10
11variable "instance_type" {
12 type = string
13
14 validation {
15 condition = can(regex("^t3\\.", var.instance_type))
16 error_message = "Only t3 instance types are allowed in this module."
17 }
18}
1│ Error: Invalid value for variable
2│ on variables.tf line 1:
3│ 1: variable "environment" {
4│ environment must be one of: dev, staging, prod.
A variable can have several validation blocks. Since Terraform 1.9 a condition may also reference other variables and data sources, so "if multi_az is true, instance_count must be at least 2" is possible.
sensitive - hides the value in plan and apply output. Use it for passwords, tokens and keys -
1variable "db_password" {
2 type = string
3 sensitive = true
4}
Two things to know - "Terraform still stores the values of sensitive variables in your state", so protect the state file, and a sensitive value cannot be used as a for_each key. More in securing sensitive data in Terraform.
ephemeral - (Terraform 1.10+) the value is not written to the state or plan file at all. It is the right choice for a short-lived credential that is only needed during the apply, for example a Vault token. Ephemeral values can only flow into other ephemeral things - provider configuration, provisioners, other ephemeral variables and outputs - which "does add restrictions to the values you can assign that variable", as the docs put it.
1variable "vault_token" {
2 type = string
3 ephemeral = true
4}
nullable - by default a variable may be set to null explicitly, which then means "use the default". nullable = false forbids that, which is what you want for almost every variable in a module.
7. Terraform Locals
Input variables are for values that come from outside. For values you compute inside the configuration - a name built from several variables, a merged tag map, a lookup result - use locals. They are not set from the command line, they are just named expressions -
1locals {
2 name_prefix = "${var.project}-${var.environment}"
3
4 common_tags = merge(
5 {
6 Project = var.project
7 Environment = var.environment
8 ManagedBy = "terraform"
9 },
10 var.extra_tags
11 )
12}
13
14resource "aws_instance" "web" {
15 ami = data.aws_ami.ubuntu.id
16 instance_type = var.instance_type
17 tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })
18}
You read them with local.<name> (singular). I have a dedicated post with more patterns in how to use Terraform locals, and for the string tricks (format, join, replace) see managing strings in Terraform.
8. Terraform Output Variables
Output values are how you get information out of a configuration - the public IP of the instance you just created, the ARN of a role, the URL of a load balancer. They are printed at the end of terraform apply, readable with terraform output, and they are the way a module returns values to its caller.
1output "instance_ip" {
2 description = "Public IP of the web server"
3 value = aws_instance.ec2_example.public_ip
4}
5
6output "instance_ips" {
7 description = "Public IPs when using count"
8 value = aws_instance.ec2_example[*].public_ip
9}
The terraform output command -
1terraform output # all outputs
2terraform output instance_ip # one value, quoted
3terraform output -raw instance_ip # one value, raw - for scripts
4terraform output -json # everything as JSON - for jq and CI
1$ terraform output -raw instance_ip
252.11.222.33
Referencing outputs from other Terraform code - inside the same configuration you do not reference an output - you reference the resource attribute directly (aws_instance.ec2_example.public_ip). The first version of this post showed output.instance_ip in a resource, and that is not valid Terraform. Outputs are read in two other places -
- From a module -
module.web.instance_ip, which is how module outputs become inputs for another module. - From another state file - with a
terraform_remote_statedata source,data.terraform_remote_state.network.outputs.subnet_id, covered in managing Terraform state.
Sensitive outputs - sensitive = true masks the value in the apply output (you can still get it with terraform output -raw). Terraform requires it when the value comes from a sensitive variable. All the details are in how to use Terraform output values.
9. Common errors with Terraform variables
1. Error: No value for required variable - The variable has no default and nothing assigned it. Add a default, a terraform.tfvars entry, -var, or TF_VAR_.
2. Error: Value for undeclared variable - You passed -var="foo=1" but there is no variable "foo" block. Declare it. (Full write-up.)
3. Error: Invalid value for input variable ... string required / number required - The type does not match. A common one is passing a list where the type says string. Check the type and the tfvars file.
4. Error: Variables not allowed / Variables may not be used here - You referenced var.something in a place that is evaluated before variables exist - the backend block, required_providers, or a variable default. Backends take -backend-config instead.
5. Error: Invalid value for variable ... <your error_message> - Your own validation block fired. That is it working as designed.
6. Error: Reference to undeclared input variable - A typo in var.instance_typ, or the variable is declared in a different module. Inside a module, only that module's variables are visible.
7. The value in terraform.tfvars is ignored - Something with higher precedence overrides it - a *.auto.tfvars file, a -var-file, a -var, or a TF_VAR_ variable left in the shell. env | grep TF_VAR_ finds the last one.
8. Error: Sensitive values are not allowed in for_each - The set or map you iterate contains a sensitive value. Use nonsensitive() only if you are sure the keys themselves are not secret.
9. Using a variable inside a variable - default = "${var.a}-x" is not allowed, variable defaults must be constants. Build the combined value in locals. (Variable within variable has the patterns.)
10. Conclusion
Variables are what turn a Terraform file into a reusable configuration. To summarise -
- Declare every input in a
variableblock with atypeand adescription; add adefaultonly when there is a sensible one. - Use
string,numberandboolfor single values,list/set/mapfor collections of one type, andobjectwithoptional()for anything structured. - Assign values with
terraform.tfvars(automatic),-var-fileper environment,TF_VAR_in CI, and-varfor one-off overrides - remembering that later sources override earlier ones. - Add
validationblocks, mark secretssensitive(orephemeralon 1.10+), and keep computed values inlocals. - Expose results with
outputand read them withterraform output -rawor from a module asmodule.<name>.<output>.
The official references are the pages on input variables and output values, and the rest of my Terraform series is below.
Read More - Terragrunt -