Quick answer: Terraform variables let you pass values into a configuration β region, instance size, environment name β so the same code works for dev, staging and production. Expressions let you compute values from those inputs using references, conditionals, loops and more than 100 built-in functions such as cidrsubnet, format and lookup. Together they turn a one-off script into a reusable template.
This guide covers every variable type, the ways values can be supplied and which one wins, local values, the expression features you will use daily, and a hands-on example that builds a VPC with subnets calculated automatically. It also updates the old "${var.x}" style that still appears in many tutorials to modern Terraform 1.x syntax.
Declaring input variables
An input variable is declared with a variable block, usually in variables.tf. Only the name is required, but production code should always set type and description:
variable "region" {
type = string
description = "AWS region where resources are created"
default = "ap-south-1"
}
variable "instance_count" {
type = number
description = "How many web servers to launch"
default = 2
validation {
condition = var.instance_count >= 1 && var.instance_count <= 10
error_message = "instance_count must be between 1 and 10."
}
}
variable "allowed_cidrs" {
type = list(string)
description = "CIDR ranges allowed to reach the load balancer"
default = ["0.0.0.0/0"]
}
variable "tags" {
type = map(string)
description = "Tags applied to every resource"
default = {}
}
variable "db_password" {
type = string
sensitive = true
}
A variable without a default is required; Terraform will prompt for it or fail in CI. Marking a variable sensitive = true hides its value from plan output and logs. The validation block (Terraform 0.13+) catches bad input before any API call is made.
Variable types at a glance
| Type | Example value | Typical use |
|---|---|---|
string |
"ap-south-1" |
Names, regions, IDs |
number |
3, 0.5 |
Counts, sizes, ports |
bool |
true |
Feature toggles such as enable_nat |
list(T) |
["a", "b"] |
Ordered items: subnets, AZs |
set(T) |
toset(["a", "b"]) |
Unique items for for_each |
map(T) |
{ env = "dev" } |
Key/value pairs: tags, lookups |
object({...}) |
{ name = "x", size = 20 } |
Structured input with fixed attributes |
any |
anything | Avoid except in generic modules |
Object types are especially useful in modules: one variable such as database = object({ engine = string, size = number }) is clearer than five separate strings and numbers.
Supplying values and the precedence order
Terraform looks for variable values in several places. When the same variable is set more than once, the later source in this list wins:
- Environment variables named
TF_VAR_<name>, for exampleexport TF_VAR_region=ap-south-1. - A
terraform.tfvarsorterraform.tfvars.jsonfile in the working directory. - Any
*.auto.tfvarsfiles, in alphabetical order. -varand-var-fileflags on the command line, in the order given.
A common team pattern is one file per environment β dev.tfvars, prod.tfvars β applied with terraform apply -var-file=prod.tfvars. Secrets such as db_password should come from TF_VAR_ environment variables injected by the CI system or a secrets manager, never from a committed file.
Local values: named expressions
locals are not inputs; they are computed values you name once and reuse, which keeps long expressions out of resource blocks:
locals {
name_prefix = "tkh-${var.environment}"
common_tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "terraform"
})
is_prod = var.environment == "prod"
}
Reference them as local.name_prefix. If you find yourself pasting the same merge() into six resources, that is a local waiting to be written.
Expressions you will use every day
Since Terraform 0.12, expressions are written directly, not inside "${...}". The interpolation syntax is only needed when you embed a value inside a larger string.
- References β
var.region,local.is_prod,aws_vpc.main.id,module.network.subnet_ids. - String templates β
"${local.name_prefix}-web". - Conditionals β
instance_type = local.is_prod ? "t3.large" : "t3.micro". forexpressions β[for s in var.subnets : upper(s)]or{ for k, v in var.tags : k => lower(v) }.- Splat β
aws_instance.web[*].idcollects one attribute from every instance created withcount. - Functions β
length(),join(),format(),lookup(),try(),cidrsubnet(),templatefile(),jsonencode()and many more. Terraform has no user-defined functions; combine the built-ins or move logic into a module.
Try any expression interactively with terraform console. Type cidrsubnet("10.0.0.0/16", 8, 3) and it answers "10.0.3.0/24" β far faster than running a plan to check.
Hands-on: a VPC with calculated subnets
The example below uses count, cidrsubnet and a data source so adding a third subnet is a one-number change:
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "subnet_count" {
type = number
default = 2
}
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
tags = merge(local.common_tags, { Name = "${local.name_prefix}-vpc" })
}
resource "aws_subnet" "private" {
count = var.subnet_count
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = merge(local.common_tags, {
Name = format("%s-private-%02d", local.name_prefix, count.index + 1)
})
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
cidrsubnet(var.vpc_cidr, 8, count.index) adds 8 bits to the /16 prefix, giving /24 subnets numbered 0, 1, 2. The availability zone comes from a data source rather than string concatenation like "${var.region}a", which fails in regions whose zones are not lettered consecutively. For the dependency implications of referencing aws_vpc.main.id, see Understanding Resource Dependencies and Ordering in Terraform.
Variable and expression mistakes to avoid
- Legacy interpolation everywhere.
"${var.region}"still works butterraform fmtflags it. Writevar.region; use${}only inside longer strings. - Omitting
type. Without it a user can pass a list where you expect a string, and the error surfaces deep inside a resource instead of at the variable. - Committing secrets in
terraform.tfvars. UseTF_VAR_variables, a secrets manager data source, or HCP Terraform variable sets. - Using
countfor named things. Removing item 0 from a list shifts every index and forces replacement of everything. Usefor_eachwith a map or set so each resource has a stable key. - Building availability zones by string concatenation. Query
aws_availability_zonesinstead; not every region hasa,b,c.
Variables are also the interface of every module you publish; Terraform Module Versioning and Publishing covers how to evolve that interface without breaking consumers.
Frequently asked questions
What is the difference between a variable and a local?
A variable is an input set from outside the configuration. A local is an internal named expression computed from variables, resources or other locals. Locals cannot be overridden by users.
Can I use a variable inside another variable’s default?
No. Defaults must be literal values. Compute derived values in locals instead.
How do I pass a list on the command line?
Use HCL syntax inside quotes: terraform apply -var='allowed_cidrs=["10.0.0.0/8","192.168.0.0/16"]'. For anything complex, a .tfvars file is easier to read.
Does marking a variable sensitive encrypt it?
No. It only redacts the value from CLI output. The value is still stored in plain text in the state file, which is why state must live in an encrypted, access-controlled backend.
Key takeaways
- Declare every variable with a
typeanddescription; addvalidationfor anything with constraints. - Supply values via
.tfvarsfiles per environment andTF_VAR_variables for secrets. - Use
localsto name repeated expressions, andterraform consoleto test them. - Prefer
for_eachovercountfor named resources, and data sources over string-built IDs. - Write modern expressions directly; reserve
${}for string templates.
Want to practise building flexible, multi-environment Terraform configurations on real AWS and Azure accounts? Our DevOps course covers Terraform end to end with live projects, mentor support and placement assistance. Prefer video? Follow along on our YouTube channel.


