Variables bring values in. Outputs send values out. Locals name things in between, and data sources read the world you did not build. Together they keep a configuration from becoming a wall of repeated literals.
Topic 1: Locals
A local names an expression so it can be reused without repetition. Where a variable is an input the caller supplies, a local is a value the module computes for itself.
locals {
name_prefix = "${var.project}-${var.environment}"
common_tags = {
Team = "platform"
Environment = var.environment
ManagedBy = "terraform"
}
subnet_names = [for cidr in var.private_cidrs : "${local.name_prefix}-${cidr}"]
}
Reference as local.<name>. Locals can refer to variables, resource attributes, data sources and other locals — but not to themselves, directly or through a cycle. That is an error, not an infinite loop.
The common_tags pattern is the one you will use most:
resource "aws_instance" "app" {
tags = merge(local.common_tags, { Name = "${local.name_prefix}-app" })
}
Group related locals into one block, particularly when they depend on each other. Scattering them across files makes the dependency chain invisible.
Topic 2: Outputs
output "instance_public_ip" {
value = aws_instance.web.public_ip
description = "Public IP address of the web instance"
}
output "db_endpoint" {
value = aws_db_instance.app.endpoint
sensitive = true
}
Outputs do three jobs:
- Echo values after apply. Provisioning a bastion and printing its IP saves a trip to the console.
- Return values from a module to its caller.
- Expose values to tooling via
terraform output -json, which is how you feed a configuration-management inventory or a downstream pipeline step.
terraform output # everything
terraform output instance_ip # one value
terraform output -json # machine-readable
Terraform prints outputs in alphabetical order, not declaration order — a reminder that block ordering in a file means nothing.
Since 0.12 you can output an entire resource, returning every exported attribute:
output "bucket" {
value = aws_s3_bucket.artifacts
}
Genuinely useful when debugging: rather than guessing which attribute you need, output the whole object once and read what is available.
The sensitive caveat, again:
Marking an output sensitive stops Terraform printing it. It does not remove it from state.
Topic 3: Data Sources
A data source is a read-only view of something Terraform does not manage. It creates nothing; it looks things up.
data "aws_ami" "base" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-2.0.*-x86_64-gp2"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.base.id
instance_type = "t3.micro"
}
Reference form: data.<type>.<local_name>.<attribute>.
Topic 4: Never Compute What You Could Reference
The most consequential idea in this lesson, and easy to get wrong because the wrong version works.
Many resource identifiers follow a predictable format. You could build one with string interpolation instead of looking it up. Do not.
# Wrong — produces a string that is usually correct
Resource = "arn:aws:s3:::${var.bucket_name}/*"
# Right — creates a real dependency
data "aws_s3_bucket" "artifacts" {
bucket = var.bucket_name
}
Resource = "${data.aws_s3_bucket.artifacts.arn}/*"
Three things the data source gives you that the computed string does not:
- It fails when the bucket does not exist. The computed version happily produces a policy pointing at nothing, and you discover the problem when something is denied at runtime.
- It tracks change. If the returned attribute moves, your configuration follows. A hardcoded format assumption breaks silently the day the provider changes it.
- It is a graph edge. Terraform knows there is a dependency and orders accordingly.
The same reasoning applies to resources you do manage — always reference aws_vpc.main.id, never a literal copied from the console.
The other reasons data sources earn their place:
- Cross-project references. As a project grows you split it into separate state files. Data sources let one project read another’s resources without managing them.
- Avoiding volatile literals. Machine image IDs change on every vendor release; a data source resolves the current one at plan time.
- Adopting existing infrastructure gradually. When migrating a hand-built estate, data sources let new code reference the old world before you import it.
Topic 5: Choosing Between Them
| You need | Use |
|---|---|
| A value the caller sets | variable |
| A value computed inside the module | local |
| A value returned to the caller or to tooling | output |
| A value read from infrastructure you do not manage | data |
| A value read from infrastructure you do manage | A direct resource reference |
Try it yourself: Take a configuration with a repeated tag block and collapse it into a locals map applied with merge(). Change one tag value and count how many places you edited — one is the target.
Common mistake: Using a variable where a data source belongs. Passing a machine image ID in as a variable means somebody has to look it up and keep it current forever. A data source filtering for the newest matching image removes that job, and the stale-value bug with it.