Outputs and Locals
The idea in one sentence
Section titled “The idea in one sentence”An output exposes a value out of a Terraform configuration after apply, while a local computes a named expression once for reuse inside the same configuration — neither one is a variable, and each solves a different problem.
Declaring an output block
Section titled “Declaring an output block”An output block takes a name, a value expression, and optionally a description and sensitive flag. After terraform apply finishes, every declared output is printed, and you can query it again later without re-running apply.
resource "google_compute_instance" "web" { name = "web-server" machine_type = "e2-medium" zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" access_config {} }}
output "instance_id" { description = "The unique identifier Google Cloud assigned to the instance" value = google_compute_instance.web.instance_id}
output "instance_external_ip" { description = "Public IP address of the web server" value = google_compute_instance.web.network_interface[0].access_config[0].nat_ip}
output "db_password" { description = "Generated password for the application database user" value = random_password.db.result sensitive = true}# Print every output declared in the configurationterraform output
# Print a single output by name, unquoted for use in scriptsterraform output instance_external_ip
# Print all outputs as JSON, for piping into another toolterraform output -jsonterraform output -json matters more than it looks: it turns your outputs into structured data that another script, a CI pipeline, or a wrapper tool can parse reliably, instead of scraping human-formatted terminal text. The sensitive = true flag on db_password works exactly like it does on a variable — Terraform masks the value in terraform output and in apply output, but the value is still written to the state file in plain text. It is a display protection, not an access control.
Proven end to end on a sensitive = true output:
$ terraform output # bare -- redacteddb_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) and postcondition (checked against the resource’s own attributes, via self, after it is planned). A real plan against google_compute_instance needs a real GCP project this course never has, so this is proven with terraform test and mock_provider "google" {} — Terraform’s own offline mocking mechanism:
variable "machine_type" { type = string default = "not-a-real-type"}
resource "google_compute_instance" "web" { name = "web-server" machine_type = var.machine_type zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" access_config {} }
lifecycle { precondition { condition = can(regex("^[a-z0-9]+-[a-z0-9]+$", var.machine_type)) error_message = "machine_type must look like a real GCE machine type, e.g. e2-medium. Got: ${var.machine_type}" } }}mock_provider "google" {}
variables { machine_type = "not-a-real-type"}
run "shows_real_error" { command = plan}Real failure, terraform test against the mocked provider:
$ terraform test╷│ Error: Resource precondition failed││ on main.tf line 24, in resource "google_compute_instance" "web":│ 24: condition = can(regex("^[a-z0-9]+-[a-z0-9]+$", var.machine_type))│ ├────────────────│ │ var.machine_type is "not-a-real-type"││ machine_type must look like a real GCE machine type, e.g. e2-medium. Got:│ not-a-real-type╵
Failure! 0 passed, 1 failed.Written as an actual test with expect_failures (the pattern the Testing Modules module covers in depth), the same run passes instead of failing, because the precondition failing IS the expected, tested behavior:
run "rejects_bad_machine_type" { command = plan
expect_failures = [ google_compute_instance.web.lifecycle[0].precondition[0], ]}$ terraform testrun "rejects_bad_machine_type"... passSuccess! 1 passed, 0 failed.A postcondition needs a real self after apply, which needs a resource to actually be created — proven instead with terraform_data (the same lifecycle mechanism, cloud-agnostic, and runnable with zero mocking):
resource "terraform_data" "web" { input = "not-an-instance-id"
lifecycle { postcondition { condition = startswith(self.output, "i-") error_message = "web output looks wrong. Got: ${self.output}" } }}$ terraform apply -auto-approveterraform_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 looks wrong. 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 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)
postcondition { condition = startswith(self.value, "i-") error_message = "web_instance_id must look like an instance id (start with i-). Got: ${self.value}" }}$ terraform validate╷│ Error: Postconditions are not allowed││ on main.tf line 8, in output "web_instance_id":│ 8: postcondition {││ Output values can only have preconditions, not postconditions.╵The precondition in that same shape 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 instance id." }}depends_on on an output
Section titled “depends_on on an output”An output’s value expression usually creates an implicit dependency automatically. 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"}
output "instance_status" { value = google_compute_instance.web.instance_id depends_on = [terraform_data.bootstrap]}Real terraform graph -type=plan DOT output confirming the extra edge depends_on adds (proven with the equivalent aws_instance/terraform_data pairing — the mechanism is identical regardless of provider, since depends_on is core Terraform, not provider-specific):
$ terraform graph -type=plan"[root] output.instance_status (expand)" -> "[root] terraform_data.bootstrap (expand)""[root] output.instance_status (expand)" -> "[root] google_compute_instance.web (expand)"locals — the third kind of named value
Section titled “locals — the third kind of named value”Terraform has three distinct kinds of named values, and it is worth being precise about the difference. A variable is set by the caller from outside. An output is exposed to the outside after apply. A local is neither: it is a named expression computed once, inside the configuration, purely so you do not have to repeat that same expression in five different resource blocks.
variable "environment" { type = string description = "Deployment environment name" default = "dev"}
variable "project_id" { type = string description = "GCP project ID"}
locals { name_prefix = "${var.project_id}-${var.environment}"
common_labels = { environment = var.environment managed_by = "terraform" }}
resource "google_compute_instance" "web" { name = "${local.name_prefix}-web" machine_type = "e2-medium" zone = "us-central1-a" labels = local.common_labels
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = "default" access_config {} }}
resource "google_storage_bucket" "assets" { name = "${local.name_prefix}-assets" location = "US" labels = local.common_labels}Without local.name_prefix and local.common_labels, that same "${var.project_id}-${var.environment}" string interpolation and that same labels map would have to be typed out again on every resource that needs them, and a later change to the naming scheme would mean hunting down every copy. A locals block is not user-settable the way a variable is, and it is not visible outside the configuration the way an output is — it exists purely to keep a computed expression in exactly one place.
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.
Outputs as the mechanism for composition
Section titled “Outputs as the mechanism for composition”Inside a single root configuration, an output is mostly informational — a convenient way to print a value at the end of apply, or to feed a script via terraform output -json. Its real importance shows up once you start splitting infrastructure into smaller, separately-managed pieces.
When one Terraform module calls another, the calling module reads the child module’s outputs to wire values between them — a networking module’s output "subnet_self_link" becomes the input to a compute module’s variable "subnet". The Modules module later in this course covers that composition in depth. The same idea shows up again, at a larger scale, in Terragrunt: an independently-applied Terragrunt unit reads another unit’s outputs through a dependency block, so a VPC deployed in one directory can hand its network name to a database deployed in a completely separate directory, with no shared Terraform state. Outputs are what make both of these forms of composition possible — without a defined, stable output, there is nothing for the consuming module or unit to read.
flowchart LR v1["var.project_id"] --> l["local.name_prefix"] v2["var.environment"] --> l l --> r1["google_compute_instance.web"] l --> r2["google_storage_bucket.assets"] r1 --> o["output ip_address"] o --> ext["Consumed by another module or a Terragrunt dependency block"]
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Resource precondition | Stops a bad plan before any resource is even touched (proven above against google_compute_instance via terraform test) | Only has access to values already known before this resource is planned — can’t check the resource’s own post-apply attributes |
Resource postcondition | Can 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 precondition | Fails a plan/apply before a broken value is ever published to a consumer | No postcondition at all — proven above as a real, immediate terraform validate error, not a softer warning |
depends_on on an output | Forces 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 |
Common Misconceptions
Section titled “Common Misconceptions”- “An output can have both a precondition and a postcondition, just like a resource’s lifecycle block.” Proven above: it cannot.
terraform validaterejects apostconditionon an output immediately with “Output values can only have preconditions, not postconditions.” - “
terraform output -jsonis 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": truemarker — piping it into a CI log or chat message still leaks the secret in full. - “Locals are evaluated top to bottom, so
local.bmust be defined abovelocal.aifausesb.” 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. - “You can’t test a resource
preconditionwithout a real GCP project.” Proven above:terraform testwithmock_provider "google" {}runs the plan-time graph, including preconditions, entirely offline.
Reading AI-generated code
Section titled “Reading AI-generated code”- A
postconditionblock generated inside anoutput— this is a realterraform validateerror today, proven above; onlypreconditionis legal on an output. - A precondition/postcondition
error_messagethat referencesselfinside an output’sprecondition— an output’spreconditionruns before the value is finalized, so there is noselfto reference the way a resourcepostconditionhas. terraform output -jsonpiped straight into a log line or CI artifact for a value that’ssensitive = 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
postconditionused where apreconditionwas 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_onadded to an output as a first reflex for “these seem related,” instead of first checking whether referencing the dependency’s own attribute in thevalueexpression would create the same ordering implicitly and more legibly.
🐛 Spot the bug
output "instance_status" {value = google_compute_instance.web.instance_id
postcondition { condition = self.value > 0 error_message = "instance_status 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 validateError: Postconditions are not allowedOutput values can only have preconditions, not postconditions.The fix is either a precondition referencing the resource attribute directly (google_compute_instance.web.instance_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.