Outputs and Locals
The idea in one sentence
Section titled “The idea in one sentence”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" }
vm_count = var.environment == "prod" ? 5 : 2
# Azure Storage Account names must be 3-24 characters, lowercase letters and numbers only storage_account_name = lower(replace("${local.name_prefix}appdata", "-", ""))}
resource "azurerm_linux_virtual_machine" "web" { count = local.vm_count
name = "${local.name_prefix}-web-${count.index}" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location size = "Standard_D2s_v3" admin_username = "azureuser" network_interface_ids = [ azurerm_network_interface.web[count.index].id, ]
admin_ssh_key { username = "azureuser" public_key = file("~/.ssh/id_rsa.pub") }
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }
tags = merge(local.common_tags, { Name = "${local.name_prefix}-web-${count.index}" })}
resource "azurerm_storage_account" "app_data" { name = local.storage_account_name resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location account_tier = "Standard" account_replication_type = "LRS" tags = local.common_tags}local.common_tags is computed once from var.project and var.environment, then referenced by both azurerm_linux_virtual_machine.web and azurerm_storage_account.app_data. Without it, 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.vm_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. local.storage_account_name earns its keep even more directly — Azure Storage Account names must be 3 to 24 characters, lowercase letters and numbers only, no hyphens, so the expression strips the hyphen out of name_prefix once, in one place, instead of every resource that needs a valid storage account name repeating the same lower(replace(...)) call. 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.
Outputs: exposing a value after apply
Section titled “Outputs: exposing a value after apply”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_vm_ids" { description = "IDs of the virtual machines created for the web tier" value = azurerm_linux_virtual_machine.web[*].id}
output "app_data_storage_account_id" { description = "Resource ID of the storage account used for application data" value = azurerm_storage_account.app_data.id}
output "sql_admin_password" { description = "Administrator login password generated for the Azure SQL server" value = azurerm_mssql_server.main.administrator_login_password sensitive = true}# List every output and its valueterraform output
# Print one output's value, unquoted for scriptingterraform output web_vm_ids
# Machine-readable output, for piping into jq or another toolterraform output -json app_data_storage_account_idterraform 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 sql_admin_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 sql_admin_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 (via terraform test with mock_provider "azurerm" {}, since a real plan needs Azure credentials this course never uses):
$ terraform output # bare -- redactedsql_admin_password = <sensitive>
$ terraform output sql_admin_password # by name -- revealed"s3cr3t-value"
$ terraform output -json # JSON -- also revealed, sensitive flag included{ "sql_admin_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 azurerm needs Azure credentials this course never has, so this is proven with terraform test and mock_provider "azurerm" {} — Terraform’s own offline mocking mechanism, not a network call:
variable "vm_size" { type = string default = "not-a-real-size"}
resource "azurerm_resource_group" "main" { name = "rg-demo" location = "eastus"}
resource "azurerm_network_interface" "web" { name = "web-nic" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location
ip_configuration { name = "internal" subnet_id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-demo/providers/Microsoft.Network/virtualNetworks/vnet/subnets/subnet1" private_ip_address_allocation = "Dynamic" }}
resource "azurerm_linux_virtual_machine" "web" { name = "web-vm" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location size = var.vm_size admin_username = "azureuser" network_interface_ids = [ azurerm_network_interface.web.id, ]
admin_ssh_key { username = "azureuser" public_key = file("~/.ssh/id_rsa.pub") }
os_disk { caching = "ReadWrite" storage_account_type = "Standard_LRS" }
source_image_reference { publisher = "Canonical" offer = "0001-com-ubuntu-server-jammy" sku = "22_04-lts" version = "latest" }
lifecycle { precondition { condition = can(regex("^Standard_", var.vm_size)) error_message = "vm_size must be a valid Azure VM size, e.g. Standard_D2s_v3. Got: ${var.vm_size}" } }}mock_provider "azurerm" {}
variables { vm_size = "not-a-real-size"}
run "shows_real_error" { command = plan}Real failure, terraform test against the mocked provider:
$ terraform test╷│ Error: Resource precondition failed││ on main.tf line 52, in resource "azurerm_linux_virtual_machine" "web":│ 52: condition = can(regex("^Standard_", var.vm_size))│ ├────────────────│ │ var.vm_size is "not-a-real-size"││ vm_size must be a valid Azure VM size, e.g. Standard_D2s_v3. Got:│ not-a-real-size╵
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_vm_size" { command = plan
expect_failures = [ azurerm_linux_virtual_machine.web.lifecycle[0].precondition[0], ]}$ terraform testrun "rejects_bad_vm_size"... 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 "web_status" { value = azurerm_linux_virtual_machine.web.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.web_status (expand)" -> "[root] terraform_data.bootstrap (expand)""[root] output.web_status (expand)" -> "[root] azurerm_linux_virtual_machine.web (expand)"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 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["azurerm_linux_virtual_machine.web"] lp --> rb["azurerm_storage_account.app_data"] rb --> ob["output app_data_storage_account_id"] ob --> dep["dependency block or module output reference"]
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 azurerm_linux_virtual_machine 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 real Azure credentials.” Proven above:terraform testwithmock_provider "azurerm" {}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 "web_instance_id" {value = azurerm_linux_virtual_machine.web.id
postcondition { condition = startswith(self.value, "vm-") 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 validateError: Postconditions are not allowedOutput values can only have preconditions, not postconditions.The fix is either a precondition referencing the resource attribute directly (azurerm_linux_virtual_machine.web.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.