Skip to content

Outputs and Locals

A local is a named expression computed once and reused inside a configuration, while an output takes a value out of a configuration and makes it visible after apply — together with variable, they are the three distinct kinds of named value in Terraform.

Locals: computing a value once and reusing it

Section titled “Locals: computing a value once and reusing it”

A variable is set by whoever calls the configuration, and an output is exposed to whoever consumes it. A local is neither — it is an internal convenience, a name you give to an expression so you do not have to repeat that expression every time you need its result. Nobody outside the configuration can set a local, and nobody outside the configuration can read one directly either.

locals {
name_prefix = "${var.environment}-${var.project}"
common_tags = {
Project = var.project
Environment = var.environment
ManagedBy = "terraform"
}
instance_count = var.environment == "prod" ? 5 : 2
}
resource "aws_instance" "web" {
count = local.instance_count
ami = var.ami_id
instance_type = var.instance_type
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web-${count.index}"
})
}
resource "aws_s3_bucket" "app_data" {
bucket = "${local.name_prefix}-app-data"
tags = local.common_tags
}

local.common_tags is computed once from var.project and var.environment, then referenced by both aws_instance.web and aws_s3_bucket.app_data. Without it, the same merge(...) expression and the same three key-value pairs would have to be copied into every resource block that needs them, and a later change to the tagging scheme would mean hunting down every copy. local.instance_count does the same job for a small conditional: the decision “5 in prod, 2 elsewhere” is written once and referenced by name wherever it is needed. A local is never assigned by a caller and never appears in terraform plan as something you can override — it is purely a name for a computed value inside this configuration.

Locals have no fixed evaluation order — and a cycle is a real error

Section titled “Locals have no fixed evaluation order — and a cycle is a real error”

Terraform evaluates every local (and every resource attribute) lazily, in whatever order its dependency graph requires — not top to bottom as written. local.a referencing local.b referencing local.a is not “undefined behavior,” it is a real, caught error:

locals {
a = local.b
b = local.a
}
output "a" {
value = local.a
}
$ terraform plan
╷
│ Error: Cycle:
│ local.a (expand)
│ local.b (expand)
╵

This is the same class of error terraform graph (covered in the HCL, Providers & Resources lesson) makes visible for resources — Terraform builds one dependency graph across every local, resource, and output in a configuration, and a cycle anywhere in it is a hard stop before any plan is produced.

An output block takes a value that exists inside a configuration — usually an attribute of a resource that only becomes known after apply — and makes it available outside that configuration. description documents what the value means, and sensitive = true masks it from ordinary CLI output the same way it does on a variable.

output "web_instance_ids" {
description = "IDs of the EC2 instances created for the web tier"
value = aws_instance.web[*].id
}
output "app_data_bucket_arn" {
description = "ARN of the S3 bucket used for application data"
value = aws_s3_bucket.app_data.arn
}
output "db_password" {
description = "Master password generated for the RDS instance"
value = aws_db_instance.main.password
sensitive = true
}
Terminal window
# List every output and its value
terraform output
# Print one output's value, unquoted for scripting
terraform output web_instance_ids
# Machine-readable output, for piping into jq or another tool
terraform output -json app_data_bucket_arn

terraform output on its own prints every declared output after the most recent apply. terraform output <name> prints just one, and terraform output -json renders the value (or all values, if you omit a name) as JSON, which is what you want when a CI pipeline or another script needs to consume the result programmatically rather than read it as a human. The sensitive = true argument on db_password behaves exactly like it does on a variable: Terraform prints (sensitive value) in plan/apply output and in a bare terraform output, but terraform output db_password still prints the real value when you ask for it by name, and the value is still stored in plain text in state. It is display protection for terminals and logs, not access control.

Proven end to end on a sensitive = true output:

$ terraform output # bare -- redacted
db_password = <sensitive>
$ terraform output db_password # by name -- revealed
"s3cr3t-value"
$ terraform output -json # JSON -- also revealed, sensitive flag included
{
"db_password": {
"sensitive": true,
"type": "string",
"value": "s3cr3t-value"
}
}

-json does not respect sensitive = true either — it marks the field "sensitive": true for a consumer to notice, but includes the real "value" regardless. Anything that pipes terraform output -json into a log line, a CI artifact, or a chat notification prints the secret in full — the JSON flag is metadata, not redaction.

precondition and postcondition: not symmetric between resources and outputs

Section titled “precondition and postcondition: not symmetric between resources and outputs”

The lifecycle block on a resource accepts both precondition (checked before the resource is planned, using only values already known) and postcondition (checked against the resource’s own attributes, via self, after it is planned or applied):

variable "ami_id" {
type = string
default = "not-an-ami"
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
lifecycle {
precondition {
condition = can(regex("^ami-", var.ami_id))
error_message = "ami_id must look like a real AMI id, e.g. ami-0123456789abcdef0. Got: ${var.ami_id}"
}
}
}

Real precondition failure with an invalid ami_id, against the actual aws_instance resource (mock credentials, no network reached — the precondition is checked before Terraform would ever call the EC2 API):

$ terraform plan
Planning failed. Terraform encountered an error while generating this plan.
╷
│ Error: Resource precondition failed
│
│ on main.tf line 17, in resource "aws_instance" "web":
│ 17: condition = can(regex("^ami-", var.ami_id))
│ ├────────────────
│ │ var.ami_id is "not-an-ami"
│
│ ami_id must look like a real AMI id, e.g. ami-0123456789abcdef0. Got:
│ not-an-ami
╵

A postcondition needs a real apply to have a self attribute to check against, which isn’t reachable offline for aws_instance — proven instead with terraform_data (the same lifecycle mechanism, cloud-agnostic):

resource "terraform_data" "web" {
input = "not-an-instance-id"
lifecycle {
postcondition {
condition = startswith(self.output, "i-")
error_message = "web output must look like an EC2 instance id (start with i-). Got: ${self.output}"
}
}
}
$ terraform apply -auto-approve
terraform_data.web: Creating...
terraform_data.web: Creation complete after 0s [id=e18876e2-035e-cf41-d0df-1750ee7f8900]
╷
│ Error: Resource postcondition failed
│
│ on main.tf line 6, in resource "terraform_data" "web":
│ 6: condition = startswith(self.output, "i-")
│ ├────────────────
│ │ self.output is "not-an-instance-id"
│
│ web output must look like an EC2 instance id (start with i-). Got:
│ not-an-instance-id
╵

Note the resource is actually created before the postcondition check runs — unlike a precondition, a postcondition cannot prevent a resource from being created, only report the failure and stop the run after the fact.

An output block, however, only accepts precondition — attempting a postcondition on an output is a real, immediate error, not something that just behaves oddly. This contradicts a common assumption (and, worth being upfront about, an earlier draft of this course’s own plan) that outputs support both — they only support precondition:

resource "terraform_data" "web" {
input = "i-mock123"
}
output "web_instance_id" {
value = trimspace(terraform_data.web.output)
precondition {
condition = length(trimspace(terraform_data.web.output)) > 0
error_message = "web_instance_id must not be empty."
}
postcondition {
condition = startswith(self.value, "i-")
error_message = "web_instance_id must look like an EC2 instance id (start with i-). Got: ${self.value}"
}
}
$ terraform validate
╷
│ Error: Postconditions are not allowed
│
│ on main.tf line 13, in output "web_instance_id":
│ 13: postcondition {
│
│ Output values can only have preconditions, not postconditions.
╵

The precondition in that same block is legal and does what you would expect — it just has to express the check as something true before publishing, referencing the resources the output reads from directly (an output has no self.value to check against inside a precondition, since the value isn’t “self” yet at that point):

output "web_instance_id" {
value = terraform_data.web.output
precondition {
condition = startswith(terraform_data.web.output, "i-")
error_message = "web_instance_id must look like an EC2 instance id."
}
}

An output’s value expression usually creates an implicit dependency automatically (referencing aws_instance.web.id already tells Terraform the output depends on that instance). depends_on is for the rarer case: forcing an explicit ordering the value expression itself doesn’t reveal, e.g. exposing an output only after an unrelated resource with side effects has been created:

resource "terraform_data" "bootstrap" {
input = "bootstrap-done"
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
}
output "web_status" {
value = aws_instance.web.id
depends_on = [terraform_data.bootstrap]
}

Real terraform graph -type=plan DOT output confirming the extra edge depends_on adds (that edge would not exist from value alone, since the output’s value expression never references terraform_data.bootstrap):

$ terraform graph -type=plan
"[root] output.web_status (expand)" -> "[root] terraform_data.bootstrap (expand)"
"[root] output.web_status (expand)" -> "[root] aws_instance.web (expand)"

Inside a single root configuration, an output is mostly informational — a convenient way to surface a value for a human running terraform apply to glance at. Its real importance shows up once a configuration stops being a single, self-contained thing. When you split infrastructure into modules, a module has no other way to hand a computed value back to whoever called it except through its output blocks — the Modules module later in this course covers module.<name>.<output> references in depth. When you split infrastructure into independently-deployed Terragrunt units instead, a dependency block reads another unit’s outputs to wire units together without ever hard-coding a value between them — that pattern gets its own treatment in a later Terragrunt module. In both cases, the mechanism is the same one shown here: a value becomes known inside one configuration, an output block exposes it, and something else — a parent module or a dependent unit — consumes it.

flowchart LR
  ve["var.environment"] --> lp["local.name_prefix"]
  vp["var.project"] --> lp
  lp --> ri["aws_instance.web"]
  lp --> rb["aws_s3_bucket.app_data"]
  rb --> ob["output app_data_bucket_arn"]
  ob --> dep["dependency block or module output reference"]
A local feeds two resources, and a resource attribute becomes an output

Terraform maintains exactly one dependency graph across every local, resource, and output in a configuration — there is no separate “locals pass” that runs before “resource pass.” That single fact explains everything above: a locals cycle is caught the same way a resource cycle would be, because it’s the same graph; a precondition on a resource can gate whether the resource is even planned because the resource node hasn’t been reached yet, while a resource postcondition can only report failure after the fact because by the time self exists, the apply already happened; and an output’s depends_on is meaningful precisely because the graph is real and edges in it change execution order, not just documentation.

ChoiceBenefitCost
Resource preconditionStops a bad plan before any resource is even touched (proven above with aws_instance)Only has access to values already known before this resource is planned — can’t check the resource’s own post-apply attributes
Resource postconditionCan validate the resource’s own real, post-apply attributes via self (proven above)The resource has already been created (or changed) by the time it fails — proven above, terraform_data.web existed before the postcondition error stopped the run
Output preconditionFails a plan/apply before a broken value is ever published to a consumerNo postcondition at all — proven above as a real, immediate terraform validate error, not a softer warning
depends_on on an outputForces an ordering the value expression alone doesn’t reveal (proven above via real terraform graph DOT output)Easy to reach for when the real fix is restructuring the value expression to reference the dependency directly, which documents why the dependency exists instead of leaving a bare resource reference
  • “An output can have both a precondition and a postcondition, just like a resource’s lifecycle block.” Proven above: it cannot. terraform validate rejects a postcondition on an output immediately with “Output values can only have preconditions, not postconditions.”
  • “terraform output -json is safe for sensitive values because it’s meant for machines, not humans.” Proven above: the JSON output includes the real value and only adds a "sensitive": true marker — piping it into a CI log or chat message still leaks the secret in full.
  • “Locals are evaluated top to bottom, so local.b must be defined above local.a if a uses b.” Terraform builds a dependency graph and evaluates lazily — order in the file doesn’t matter for validity, only for a human reading it. What is invalid is a genuine cycle, proven above with a real, unambiguous error.
  • “A resource postcondition can prevent the resource from being created if it fails.” Proven above: the resource is created first (terraform_data.web: Creation complete), and only then does the postcondition fail and stop the run — it reports and halts, it doesn’t roll back.
  • A postcondition block generated inside an output — this is a real terraform validate error today, proven above; only precondition is legal on an output.
  • A precondition/postcondition error_message that references self inside an output’s precondition — an output’s precondition runs before the value is finalized, so there is no self to reference the way a resource postcondition has.
  • terraform output -json piped straight into a log line or CI artifact for a value that’s sensitive = true — proven above, this still reveals the real value.
  • Locals written in an order that “looks” dependent top to bottom, with a comment claiming order matters — Terraform’s evaluation order is graph-driven, not textual; the comment is misleading even if the code happens to work.
  • A resource postcondition used where a precondition was actually needed (checking something knowable before the resource is planned, at apply-time cost the check didn’t need to pay) — proven above, a precondition can stop a plan before any resource is touched; a postcondition cannot.
  • depends_on added to an output as a first reflex for “these seem related,” instead of first checking whether referencing the dependency’s own attribute in the value expression would create the same ordering implicitly and more legibly.

🐛 Spot the bug

output "web_instance_id" {
value = aws_instance.web[0].id
postcondition {
condition = startswith(self.value, "i-")
error_message = "web_instance_id looks wrong."
}
}
Show the answer

This never gets a chance to run — terraform validate rejects the whole configuration before any plan is produced, because outputs do not support postcondition at all. Proven above with the identical block shape:

$ terraform validate
Error: Postconditions are not allowed
Output values can only have preconditions, not postconditions.

The fix is either a precondition referencing the resource attribute directly (aws_instance.web[0].id, not self.value, since an output’s precondition runs before the value is finalized), or — if the real intent is “validate the resource’s own attribute after it’s created” — a postcondition on the resource’s lifecycle block instead, where self is legal.

What is the main difference in purpose between a local and an output?
How does a local differ from a variable?
Why do outputs matter for composing infrastructure, even though modules and Terragrunt dependencies are covered in more depth later
What is terraform output -json useful for
What happens when you run terraform validate on this?
output "web_instance_id" {
  value = aws_instance.web[0].id

  postcondition {
    condition     = startswith(self.value, "i-")
    error_message = "bad id"
  }
}
A resource has a postcondition that fails after terraform apply creates it. What actually happens to the resource?
What happens when you run terraform plan against this?
locals {
  a = local.b
  b = local.a
}