Terraform local values, usually just called locals, are named expressions defined inside a locals block. They let you store a calculated value once and then reuse it throughout your configuration.
If input variables are the values that come into your Terraform code, local values are the values you compute and organize inside it.
A basic example looks like this:
variable "environment" {
type = string
}
variable "project_name" {
type = string
}
locals {
name_prefix = "${var.project_name}-${var.environment}"
}
resource "aws_s3_bucket" "logs" {
bucket = "${local.name_prefix}-logs"
}Here, local.name_prefix saves you from repeating the same string construction everywhere.
Locals do not accept input from the user. They are not part of the public interface of a module. They are strictly an internal convenience that improves readability and removes duplication.
Beginners sometimes skip locals and write everything inline. Terraform allows that, but the configuration becomes noisy very quickly.
For example:
resource "aws_cloudwatch_log_group" "app" {
name = "/aws/${var.project_name}/${var.environment}/app"
}
resource "aws_s3_bucket" "logs" {
bucket = "${var.project_name}-${var.environment}-logs"
}
resource "aws_iam_role" "app" {
name = "${var.project_name}-${var.environment}-app-role"
}The repeated project_name + environment pattern appears everywhere. If your naming convention changes, you have to update many places.
With locals:
locals {
name_prefix = "${var.project_name}-${var.environment}"
}
resource "aws_cloudwatch_log_group" "app" {
name = "/aws/${local.name_prefix}/app"
}
resource "aws_s3_bucket" "logs" {
bucket = "${local.name_prefix}-logs"
}
resource "aws_iam_role" "app" {
name = "${local.name_prefix}-app-role"
}This is easier to read and easier to change.
locals BlockA locals block can define one or many local values:
locals {
environment_upper = upper(var.environment)
common_tags = {
Environment = var.environment
Project = var.project_name
ManagedBy = "Terraform"
}
enable_backups = var.environment == "prod"
}You reference them with local.<name>:
tags = local.common_tagsNote the syntax carefully:
var.name for input variableslocal.name for local valuesmodule.name.output_name for module outputsThis is one of the most important conceptual distinctions in Terraform.
Variables are for values that should be supplied by:
.tfvars fileExample:
variable "region" {
type = string
}The region is a decision the caller should make, so it belongs in a variable.
Locals are for values derived from variables, resources, or expressions inside the module.
Example:
locals {
bucket_name = "${var.project_name}-${var.environment}-logs"
}This does not need to be exposed to the caller. It is implementation detail.
Data sources fetch existing information from a provider.
Example:
data "aws_availability_zones" "available" {}This value comes from AWS, not from the user and not from pure local computation.
Ask this question:
Understanding this boundary keeps your module interfaces clean.
Local values can contain almost any Terraform expression. This makes them very powerful.
locals {
name_prefix = "${var.project_name}-${var.environment}"
log_group = "/aws/${var.project_name}/${var.environment}/app"
}locals {
desired_capacity = var.environment == "prod" ? 4 : 2
total_disk_size = var.instance_count * var.disk_size_gb
}locals {
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
}
private_subnet_names = [
for index, az in var.availability_zones :
"${var.project_name}-${var.environment}-private-${index + 1}"
]
}Locals can use resource attributes too:
locals {
alb_dns_name = aws_lb.app.dns_name
}This is valid, but use it thoughtfully. If the value is only used once, creating a local may add unnecessary indirection.
Terraform includes many built-in functions, and locals are a great place to use them.
merge()A classic pattern is combining default tags with user-supplied tags.
variable "tags" {
type = map(string)
default = {}
}
locals {
common_tags = merge(
{
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
},
var.tags
)
}Now every resource can reuse local.common_tags.
lower(), upper(), replace()Useful for naming standards:
locals {
normalized_project = replace(lower(var.project_name), "_", "-")
name_prefix = "${local.normalized_project}-${var.environment}"
}concat() and distinct()Useful for combining lists:
locals {
all_cidrs = distinct(concat(var.office_cidrs, var.vpn_cidrs))
}lookup()A common way to choose environment-specific values:
locals {
instance_size = lookup(
{
dev = "t3.micro"
staging = "t3.small"
prod = "t3.medium"
},
var.environment,
"t3.micro"
)
}jsonencode()Great for IAM policies or ECS task definitions:
locals {
bucket_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = "${aws_s3_bucket.assets.arn}/*"
}
]
})
}This keeps complex JSON generation readable.
Locals often centralize environment-specific decisions.
locals {
instance_type = var.environment == "prod" ? "t3.medium" : "t3.micro"
backup_days = var.environment == "prod" ? 30 : 7
multi_az = var.environment == "prod" ? true : false
}Now you express the rule once and reuse it everywhere.
You can also make conditionals more expressive by grouping them in locals rather than scattering ternary operators across resource blocks.
This is probably the most common real-world use of locals.
locals {
common_tags = merge(
{
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
Owner = "platform-team"
},
var.extra_tags
)
}Then:
resource "aws_instance" "web" {
ami = "ami-0abcdef1234567890"
instance_type = var.instance_type
tags = local.common_tags
}This keeps tags consistent across your infrastructure.
locals {
name_prefix = "${var.project_name}-${var.environment}"
}Then:
resource "aws_security_group" "app" {
name = "${local.name_prefix}-sg"
}
resource "aws_s3_bucket" "artifacts" {
bucket = "${local.name_prefix}-artifacts"
}If the naming convention changes later, you update one local value instead of many resources.
locals {
desired_count = {
dev = 1
staging = 2
prod = 4
}[var.environment]
}This is cleaner than repeating the same environment branching logic in multiple places.
locals {
public_subnet_names = [
for index, az in var.availability_zones :
"${var.project_name}-${var.environment}-public-${index + 1}"
]
}locals {
enable_waf = var.environment == "prod"
enable_monitoring = contains(["staging", "prod"], var.environment)
}This keeps business rules in one place.
variable "project_name" {
type = string
}
variable "environment" {
type = string
}
variable "availability_zones" {
type = list(string)
}
variable "extra_tags" {
type = map(string)
default = {}
}
locals {
normalized_project = replace(lower(var.project_name), "_", "-")
name_prefix = "${local.normalized_project}-${var.environment}"
common_tags = merge(
{
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
},
var.extra_tags
)
instance_type = {
dev = "t3.micro"
staging = "t3.small"
prod = "t3.medium"
}[var.environment]
backup_retention_days = var.environment == "prod" ? 30 : 7
}
resource "aws_s3_bucket" "logs" {
bucket = "${local.name_prefix}-logs"
tags = local.common_tags
}
resource "aws_db_instance" "app" {
allocated_storage = 20
engine = "postgres"
instance_class = local.instance_type == "t3.medium" ? "db.t3.medium" : "db.t3.micro"
backup_retention_period = local.backup_retention_days
}This demonstrates the main purpose of locals: moving repeated logic and naming rules into one understandable section.
Locals are helpful, but too many locals can make Terraform harder to read.
locals {
bucket_acl = "private"
}If a value is only used once and adds no clarity, keeping it inline may be simpler.
locals {
s3_bucket_id = aws_s3_bucket.logs.id
}If you use that ID once, just reference aws_s3_bucket.logs.id directly.
locals {
region = var.region
}This adds one more name without adding meaning.
Use locals when they do at least one of these things:
If a local does none of those things, it may not be worth creating.
name_prefix, common_tags, and backup_retention_days are much better than x or config1.Local values are one of the easiest ways to make Terraform code more readable. They help you avoid copy-paste, keep naming conventions consistent, and express environment-specific logic once instead of in ten different places.
A useful mental model is:
If you apply that rule consistently, your Terraform code will be easier to understand and easier to maintain as it grows.
<MCQExercise exercise={{ id: 'tf-locals-q1', title: 'Question 1: Local Syntax', description: 'How do you reference a Terraform local value named name_prefix?', options: [ { id: 'a', text: 'var.name_prefix' }, { id: 'b', text: 'locals.name_prefix' }, { id: 'c', text: 'local.name_prefix' }, { id: 'd', text: 'module.name_prefix' }, ], correctId: 'c', explanation: 'Terraform local values are referenced with the local.<name> syntax. For a local named name_prefix, the correct reference is local.name_prefix.', wrongExplanations: { a: 'var.<name> is used for input variables, not local values.', b: 'locals is the block name used when defining locals, but references use local.<name>.', d: 'module.<name> is used for outputs from child modules, not for locals.', }, }} />
<MCQExercise exercise={{ id: 'tf-locals-q2', title: 'Question 2: Variables vs Locals', description: 'Which value is usually the best candidate for a local instead of an input variable?', options: [ { id: 'a', text: 'AWS region chosen by the operator' }, { id: 'b', text: 'Database password provided by CI/CD' }, { id: 'c', text: 'A name prefix built from project_name and environment' }, { id: 'd', text: 'The VPC CIDR block that the caller must decide' }, ], correctId: 'c', explanation: 'A name prefix derived from existing inputs is internal logic, which makes it a strong use case for a local value. The other choices are caller-supplied values and should usually remain variables.', wrongExplanations: { a: 'The operator should decide the deployment region, so region is normally a variable.', b: 'Passwords are external inputs and should be passed securely as variables, not computed as locals.', d: 'The VPC CIDR block is a major design choice for the caller, so it should normally be an input variable.', }, }} />
<MCQExercise exercise={{ id: 'tf-locals-q3', title: 'Question 3: Overusing Locals', description: 'Which example best shows unnecessary overuse of Terraform locals?', options: [ { id: 'a', text: 'Creating local.common_tags and reusing it across 20 resources' }, { id: 'b', text: 'Creating local.name_prefix to centralize naming conventions' }, { id: 'c', text: 'Creating local.region = var.region and using it once instead of var.region' }, { id: 'd', text: 'Creating local.instance_size based on environment-specific rules' }, ], correctId: 'c', explanation: 'A local that simply renames a variable without adding clarity or reducing repetition is usually unnecessary. It adds indirection without improving the configuration.', wrongExplanations: { a: 'This is a strong use of locals because it removes duplication and keeps tags consistent.', b: 'Centralizing naming conventions is one of the most common and useful local-value patterns.', d: 'Using locals for environment-based logic is often cleaner than repeating conditionals throughout resource blocks.', }, }} />
Whenever the same expression appears more than twice, or whenever naming it makes the configuration easier to read. A local such as name_prefix = "${var.project}-${var.environment}" used across twenty resources means one place to change the convention, and the intent is obvious at every use site. The common-tags pattern, a locals block merging standard tags with per-resource ones, is the single most useful application.
Yes, as long as there is no circular reference. Terraform resolves them as a dependency graph, so a local can build on another declared above or below it in the file. Locals can also reference variables, data sources and resource attributes. What they cannot do is reference something that depends on them, which produces a cycle error naming the chain.
Once per plan or apply, not per resource that references them. The value is computed and reused, so a local containing an expensive expression is not recalculated for each use. Note that a local referencing a resource attribute that is unknown until apply will itself be unknown at plan time, which is why a plan sometimes shows (known after apply) in a place you expected a concrete value.
Yes, and it is the idiomatic pattern. Define a common_tags local containing your organisation's standard tags, then merge(local.common_tags, { Name = "..." }) on each resource to add specifics. Many providers also support default_tags at the provider level, which applies tags to every resource automatically. Combining the two gives you provider-wide defaults plus per-resource additions with no repetition.
Yes. A configuration where every value is indirected through a local becomes harder to follow than one with a few literals, because reading any resource means jumping to the locals block and back. Reserve them for values used in several places, for expressions complex enough that a name aids comprehension, and for anything you expect to change. A single-use local wrapping a simple literal usually costs more in navigation than it saves.
Declare Terraform output values, mark them sensitive, read them with the terraform output command and JSON, expose module outputs and use them in CI/CD.
Understand why Terraform keeps state, what is inside it, how remote backends and locking make teamwork safe, and the state commands that rescue you.
Learn what Terraform state is, why terraform.tfstate exists, how it maps to real resources, and why drift, state security and locking matter.