| Tested on | Ubuntu 26.04 LTS (Resolute Raccoon) |
|---|---|
| Package | terraform 1.15.8-1hashicorp/local 2.9.0 |
| Applies to | Any host with Terraform installed |
| Lab environment | Single Ubuntu VM with Terraform — Terraform lab environment on Ubuntu |
| Privilege | Normal user; sudo only if Terraform is not installed yet |
| Scope | Interactive expression evaluation, built-in functions, variables and locals, resource attributes read from state, unknown values with the plan flag, non-interactive piping, state lock behaviour, and common console errors. Does not cover the full function catalogue or a complete expressions tutorial. |
| Related guides | Terraform expressions Terraform functions Terraform local values Terraform variables Terraform state commands |
Writing a for expression or a nested merge() call and finding out whether it works only after terraform apply is a slow way to learn. The terraform console command gives you a read-only prompt where you type an expression and see its value immediately, using the same evaluator that plan and apply use.
That makes it the fastest debugging tool in Terraform. You can check what a local actually resolves to, confirm which key a map lookup returns, or read an attribute of a resource that already exists in state, all without touching a single .tf file.
What does terraform console do?
The console is a REPL: it reads an expression, evaluates it, prints the result, and waits for the next one. Its help text is worth reading once, because the flag list tells you most of what the command can do.
terraform console -helpUsage: terraform [global options] console [options]
Starts an interactive console for experimenting with Terraform
interpolations.
This will open an interactive console that you can use to type
interpolations into and inspect their values. This command loads the
current state. This lets you explore and test interpolations before
using them in future configurations.
This command will never modify your state.
Options:
-state=path Legacy option for the local backend only. See the local
backend's documentation for more information.
-plan Create a new plan (as if running "terraform plan") and
then evaluate expressions against its planned state,
instead of evaluating against the current state.
You can use this to inspect the effects of configuration
changes that haven't been applied yet.
-var 'foo=bar' Set a variable in the Terraform configuration. This
flag can be set multiple times.
-var-file=foo Set variables in the Terraform configuration from
a file. If "terraform.tfvars" or any ".auto.tfvars"
files are present, they will be automatically loaded.Two sentences in that text matter more than the flags. The console loads the current state, which is why it can show you real resource attributes, and it will never modify your state, which makes plain console mode safe for state inspection. Use -plan with the same caution you would use for a normal plan, because planning can execute data sources or external commands.
A common assumption is that the console needs a configuration before it will start. It does not, as long as you only ask it to evaluate pure expressions. Try it in a directory that contains nothing at all:
mkdir -p ~/terraform-labs/terraform-console/emptyTerraform has no providers to install and no state to read here, so the only thing it can do is compute the expression you hand it:
cd ~/terraform-labs/terraform-console/empty && echo 'upper("hello")' | terraform console"HELLO"No error, no warning, and no terraform init required. Anything built purely from literals and built-in functions works in an empty directory, which makes the console a handy scratchpad even when you are nowhere near a real project.
Build the lab configuration
To evaluate variables, locals, and resource attributes you need a configuration for the console to load. Create a working directory for this lesson so it stays separate from other Terraform labs:
mkdir -p ~/terraform-labs/terraform-console/mainDeclare three input variables covering a string, a list, and a map, so later sections have different types to index into:
cd ~/terraform-labs/terraform-console/main && cat > variables.tf <<'EOF'
variable "environment" {
type = string
default = "dev"
description = "Deployment environment name"
}
variable "instance_names" {
type = list(string)
default = ["web-1", "web-2", "web-3"]
}
variable "ports" {
type = map(number)
default = {
http = 80
https = 443
}
}
EOFLocals are where console debugging pays off most, because a local is derived from other values and you cannot see the result anywhere until something renders it. Add three that build on the variables above:
cat > locals.tf <<'EOF'
locals {
name = "${var.environment}-web"
tags = {
env = var.environment
owner = "platform"
}
upper_names = [for n in var.instance_names : upper(n)]
}
EOFNow the resources. The built-in terraform_data resource needs no credentials, and local_file writes a real file so you can compare an attribute Terraform computes against one the provider generates:
cat > main.tf <<'EOF'
terraform {
required_version = ">= 1.12.0"
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "terraform_data" "demo" {
input = "${var.environment}-payload"
}
resource "local_file" "notes" {
filename = "${path.module}/notes.txt"
content = "environment=${var.environment}\n"
}
output "resource_id" {
value = terraform_data.demo.id
}
EOFBefore initializing, try the console once so you can recognize the failure when it happens to you for real:
echo 'upper("terraform")' | terraform console -no-colorError: Inconsistent dependency lock file
The following dependency selections recorded in the lock file are
inconsistent with the current configuration:
- provider registry.terraform.io/hashicorp/local: required by this configuration but no version is selected
To make the initial dependency selections that will initialize the dependency
lock file, run:
terraform initThe expression itself was fine. The console never got as far as evaluating it, because the configuration now declares the local provider and Terraform cannot build an evaluation context until that provider is resolved. This is the honest version of "the console needs init": it needs it once providers or modules enter the picture, not always.
Resolve the provider with terraform init:
terraform initInitializing the backend...
Initializing provider plugins...
- terraform.io/builtin/terraform is built in to Terraform
- Finding hashicorp/local versions matching "~> 2.5"...
- Installing hashicorp/local v2.9.0...
- Installed hashicorp/local v2.9.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above.
Terraform has been successfully initialized!The ~> 2.5 constraint allows any 2.x release from 2.5 upward, so Terraform picked v2.9.0. The console can now load the full configuration.
Start an interactive session
Opening the console with no flags drops you at a > prompt against the current state:
terraform console> upper("terraform")
"TERRAFORM"
> exitType an expression, press Enter, and the value appears on the next line. When you are done, exit closes the session, and Ctrl-D does the same by sending end of file. Both return exit status zero, and Terraform prints exit on the prompt line even when you used Ctrl-D, so the two look identical afterwards. Ctrl-C closes the console as well.
Every remaining example in this guide is shown in its piped form, echo '<expression>' | terraform console, because that produces one clean result per command. Typing the same expression at the > prompt gives you exactly the same value.
Test Terraform expressions and functions
Arithmetic is the quickest way to confirm the console is evaluating rather than echoing. Operator precedence follows the usual rules, so multiplication binds tighter than addition:
echo '2 + 3 * 4' | terraform console14Parentheses override that precedence, which is worth checking when a nested expression in your configuration produces a number you did not expect:
echo '(2 + 3) * 4' | terraform console20Division exposes something surprising about how Terraform stores numbers. It does not use a float64 the way most languages would:
echo '10 / 3' | terraform console3.3333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333332That is arbitrary-precision decimal arithmetic, not a rounding bug. It rarely matters, but if you feed a division result into a string it will not look like the 3.3333333333333335 you might expect, so round explicitly with ceil, floor, or format when the value ends up in a name or a tag.
Conditionals are the expression type most worth testing before you commit them, because a wrong branch silently produces valid-looking infrastructure:
echo 'var.environment == "dev" ? "t3.small" : "t3.large"' | terraform console"t3.small"A for expression transforms one collection into another, and the console shows you the shape of the result rather than just its values:
echo '[for n in ["web-1", "web-2"] : upper(n)]' | terraform console[
"WEB-1",
"WEB-2",
]Note the trailing comma after the last element and the multi-line layout. That is how Terraform renders a collection, not a copy-paste artifact.
Indexing into a list uses a zero-based position, which is the source of many off-by-one mistakes in count blocks:
echo 'var.instance_names[0]' | terraform console"web-1"Map access uses the key instead, and quoting matters because an unquoted key would be read as a variable reference:
echo 'var.ports["https"]' | terraform console443String functions behave exactly as they will inside a resource argument. format is the one to reach for when you need zero-padded or fixed-width names:
echo 'format("%s-%03d", var.environment, 7)' | terraform console"dev-007"join collapses a list into a single string, which is how most tag and description fields end up being built:
echo 'join(", ", var.instance_names)' | terraform console"web-1, web-2, web-3"The reverse direction reveals a detail the documentation glosses over. split returns a value with an explicit type wrapper:
echo 'split("-", "web-1")' | terraform consoletolist([
"web",
"1",
])That tolist(...) wrapper tells you the result is a genuine list rather than a tuple, which matters when you pass it somewhere with a strict type constraint. Several collection functions render the same way:
echo 'keys(var.ports)' | terraform consoletolist([
"http",
"https",
])Not every collection function wraps its output, though. flatten returns a plain tuple, so the presence or absence of the wrapper is itself information about the type you are getting back:
echo 'flatten([["a"], ["b", "c"]])' | terraform console[
"a",
"b",
"c",
]merge is the function most often used to combine a common tag map with per-resource additions, and the console confirms which side wins on a key collision:
echo 'merge(local.tags, { tier = "frontend" })' | terraform console{
"env" = "dev"
"owner" = "platform"
"tier" = "frontend"
}Maps render with = between key and value and no trailing commas, which is how you can tell a map from a list at a glance in console output.
When a key might be missing, lookup supplies a fallback instead of failing:
echo 'lookup(var.ports, "ftp", 21)' | terraform console21Type conversion is worth rehearsing here because conversion errors during apply are cryptic. Asking Terraform what type it thinks a value has settles arguments quickly:
echo 'type(var.ports)' | terraform consolemap(number)The result prints unquoted because type returns a type value rather than a string. For expressions that might fail outright, try returns the first argument that evaluates successfully:
echo 'try(var.ports["ftp"], 21)' | terraform console21Its companion can turns the same failure into a boolean, which is what variable validation rules are built from:
echo 'can(var.ports["ftp"])' | terraform consolefalseEvaluate variables, locals, and tfvars overrides
Reading a variable back confirms which value actually reached Terraform, rather than which one you think is in effect:
echo 'var.environment' | terraform console"dev"Locals are the real reason to open the console. Nothing in your configuration displays local.name until something consumes it, so this is the only quick way to see what it resolved to:
echo 'local.name' | terraform console"dev-web"A local built by a for expression is even harder to picture from the source, and the console renders the whole collection:
echo 'local.upper_names' | terraform console[
"WEB-1",
"WEB-2",
"WEB-3",
]You can test a different variable value without editing any file by passing -var when the console starts:
echo 'var.environment' | terraform console -var environment=prod"prod"The important part is what happens to values derived from that variable. Locals are recomputed against the override rather than left at their original value:
echo 'local.name' | terraform console -var environment=prod"prod-web"That makes the console a genuine what-if tool: change one input and see every derived value follow, with no plan and no apply. The override lasts only for that invocation.
A terraform.tfvars file in the working directory is loaded automatically, exactly as it would be for plan and apply. Create one to confirm:
printf 'environment = "staging"\n' > terraform.tfvarsThe console picks the file up with no flag at all, and the derived local follows it:
echo 'local.name' | terraform console -no-color"staging-web"Remove the file so the rest of the lab runs against the default:
rm -f terraform.tfvarsFiles with other names are not loaded automatically, which is a frequent surprise. Those need -var-file, and this is worth testing once so you recognize the difference:
printf 'environment = "qa"\n' > custom.tfvarsPointing the console at that file explicitly gives you its value for the session:
echo 'var.environment' | terraform console -var-file=custom.tfvars -no-color"qa"Clean up the second file before moving on:
rm -f custom.tfvarsRead resource attributes before you apply
Resource attributes are where the console stops being an expression calculator and starts reading your infrastructure. Nothing has been created yet, so check what state currently holds:
terraform state list -no-colorNo state file was found!
State management commands require a state file. Run this command
in a directory where Terraform has been run or use the -state flag
to point the command to a specific state location.With no state at all, you might expect referencing a resource to fail. It does not:
echo 'terraform_data.demo.output' | terraform console(known after apply)The command exits zero and hands back a placeholder. Ask for the whole resource object and every attribute reads the same way:
echo 'terraform_data.demo' | terraform console{
"id" = (known after apply)
"input" = (known after apply)
"output" = (known after apply)
"triggers_replace" = (known after apply)
}Even input is unknown here, despite being a literal interpolation of var.environment in the configuration. Plain console mode has no plan data to work from, so it treats the entire object as unresolved. Remember this the next time an expression seems to return nothing useful. It may simply be waiting on an apply.
Work with unknown values using -plan
Plain console mode reads committed state, and with an empty state there is nothing for it to read. The -plan flag changes that. It runs a plan first and evaluates expressions against the planned result, so anything Terraform can compute from your configuration resolves right away.
terraform console -plan runs Terraform's planning phase before opening the console. It still does not write state or apply managed-resource changes, but data sources and other plan-time integrations may execute. Treat it with the same operational caution as terraform plan.
The input attribute that was unknown a moment ago makes the difference obvious:
echo 'terraform_data.demo.input' | terraform console -plan"dev-payload"Same expression, same empty state, real value. The plan filled in what the configuration already determines. Attributes the provider generates behave differently:
echo 'terraform_data.demo.output' | terraform console -plan(known after apply)output is produced by the provider during creation, so no amount of planning can reveal it early. The local_file resource shows the same split. Its content interpolates only a variable, so it is fully known:
echo 'local_file.notes.content' | terraform console -plan<<EOT
environment=dev
EOTIts identifier, by contrast, is a hash the provider computes at create time:
echo 'local_file.notes.id' | terraform console -plan(known after apply)That split is the practical lesson. When an expression still returns (known after apply) under -plan, the value genuinely does not exist yet and no debugging will produce it early. When it returns a real value, your expression is correct and you can stop looking.
| Attribute | Plain console, empty state | With -plan |
|---|---|---|
terraform_data.demo.input |
(known after apply) |
"dev-payload" |
terraform_data.demo.output |
(known after apply) |
(known after apply) |
local_file.notes.content |
(known after apply) |
known from configuration |
local_file.notes.id |
(known after apply) |
(known after apply) |
Inspect resource attributes after apply
Create the resources so the attributes become real:
terraform apply -auto-approve -no-colorTerraform will perform the following actions:
# local_file.notes will be created
+ resource "local_file" "notes" {
+ content = <<-EOT
environment=dev
EOT
+ filename = "./notes.txt"
+ id = (known after apply)
}
# terraform_data.demo will be created
+ resource "terraform_data" "demo" {
+ id = (known after apply)
+ input = "dev-payload"
+ output = (known after apply)
}
Plan: 2 to add, 0 to change, 0 to destroy.
terraform_data.demo: Creating...
terraform_data.demo: Creation complete after 0s [id=a1809f27-ad4b-9978-ec2e-8ef861ef67d3]
local_file.notes: Creating...
local_file.notes: Creation complete after 0s [id=1685b7a32904c8d97e4170def7199024e268bfb3]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
resource_id = "a1809f27-ad4b-9978-ec2e-8ef861ef67d3"Both resources exist now, so the same expression resolves to a concrete value:
echo 'terraform_data.demo.output' | terraform console"dev-payload"Reading the full object shows the difference from the pre-apply view, including an attribute that was never set:
echo 'terraform_data.demo' | terraform console{
"id" = "a1809f27-ad4b-9978-ec2e-8ef861ef67d3"
"input" = "dev-payload"
"output" = "dev-payload"
"triggers_replace" = null
}triggers_replace reads as null rather than (known after apply), and that distinction is useful: null means the value is settled and empty, while the placeholder means Terraform does not know it yet.
Multi-line strings render in heredoc form, which catches people out when they compare a console result against a literal in their configuration:
echo 'local_file.notes.content' | terraform console<<EOT
environment=dev
EOTThe blank line before EOT is the trailing newline in the file content, not a formatting quirk. Your terraform_data ID will differ from the one above, since the provider generates a fresh UUID on every create. For listing and filtering state rather than evaluating it, terraform state commands are the better tool.
Run terraform console non-interactively
Piping a single expression into the console is the pattern used throughout this guide, and it is genuinely useful in a shell script or a quick one-liner:
echo 'upper("terraform")' | terraform console"TERRAFORM"There is a trap here that costs people a lot of time. Feed several expressions in and only one result comes back:
terraform console <<'EOF'
var.environment
local.name
length(var.instance_names)
EOF3Two expressions vanished without a word. This is documented rather than a bug: when you pipe newline-separated commands in, only the output of the final one is printed unless an error occurs earlier. There is no warning, and the exit status is still zero. Reordering the lines confirms it is positional rather than a parsing failure, since whichever expression comes last is the one you get.
The same behaviour explains a confusing empty result. Piping exit after your expression throws away the answer you wanted:
printf 'upper("a")\nexit\n' | terraform console -no-colorThe command prints nothing at all and still exits zero, because exit was the last thing evaluated. Never append exit to piped input; end-of-file closes the console on its own.
So keep non-interactive use to one expression per invocation. When you need several values in a script, call the console once per expression, or reach for terraform output with -json if the values are already exposed as outputs.
Does terraform console lock your state?
Yes, and you should work as though it does. HashiCorp documents that the console holds a lock on the state, and that you cannot use the console while another operation that modifies state is running. Against a shared backend, treat an open prompt as a state-locking operation and do not count on a concurrent apply going through.
That documented contract is the rule to follow. It is still worth knowing where the lock lives and how to look for it, because that is the same file Terraform consults when an operation refuses to start. Open terraform console in a second terminal and leave it at the prompt, then check from your first terminal for the lock file the local backend writes:
ls -la .terraform.tfstate.lock.infols: cannot access '.terraform.tfstate.lock.info': No such file or directoryOn the lab built for this article the file was absent while the console sat at the prompt. A file check can also miss a lock that is held only briefly, so the stronger check is to attempt a state-writing operation with a short timeout while the session is still open:
terraform apply -auto-approve -lock-timeout=5s -no-colorterraform_data.demo: Refreshing state... [id=a1809f27-ad4b-9978-ec2e-8ef861ef67d3]
local_file.notes: Refreshing state... [id=1685b7a32904c8d97e4170def7199024e268bfb3]
No changes. Your infrastructure matches the configuration.
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.The apply finished straight away instead of waiting out its five second timeout, and starting the console with -plan behaved the same way. That is not the documented behaviour, so read it as something specific to this build and backend rather than as permission to run operations side by side.
For contrast, this is what a held lock looks like. Start an ordinary apply, and read the lock file from your other terminal while that apply is still running:
cat .terraform.tfstate.lock.info{"ID":"3434dd0f-6f54-0d57-132b-c6d28d8882c3","Operation":"OperationTypeApply","Info":"","Who":"root@golinuxcloud","Version":"1.15.8","Created":"2026-08-12T11:13:26.793016542Z","Path":"terraform.tfstate"}The lock appears within roughly a tenth of a second and names the operation holding it, which is what Terraform reports back as Error acquiring the state lock. Remote backends implement locking their own way, and Terraform state locking covers those differences. Keep the documented rule as your working assumption: expect the console to lock state, and do not schedule state-writing commands against a working directory where somebody has a console open. What holds in every case is that the console itself never writes state, so an open session cannot corrupt it.
Common terraform console problems
Most console errors share the same frame, naming <console-input> line 1 with the source unavailable, because your expression was typed rather than read from a file. The heading on the first line is the part that tells you what went wrong.
Indexing past the end of a collection is the most common one, and the message names the cause precisely:
echo 'var.instance_names[10]' | terraform console -no-colorError: Invalid index
on <console-input> line 1:
(source code not available)
The given key does not identify an element in this collection value.Attribute access on the wrong kind of value produces a different detail line under the same heading, which is the fastest way to tell a missing map key from a mistyped variable:
echo 'var.environment.foo' | terraform console -no-colorError: Unsupported attribute
on <console-input> line 1:
(source code not available)
Can't access attributes on a primitive-typed value (string).A broken configuration stops the console before it evaluates anything. Even a perfectly valid expression fails, because the working directory must parse first:
cd ~/terraform-labs/terraform-console/errors && echo 'upper("test")' | terraform console -no-colorError: Invalid expression
on main.tf line 2, in resource "local_file" "broken":
2: filename =
3: }
Expected the start of an expression, but found an invalid expression token.The error points at main.tf rather than at your input. That is a reliable signal that the problem is the directory, not the expression you typed. Fix the configuration, or change into a directory that parses, and the prompt opens normally. For wider diagnosis, the Terraform troubleshooting guide covers the same errors as they appear during plan and apply.
| Symptom | Likely cause | Fix |
|---|---|---|
Inconsistent dependency lock file |
Configuration declares a provider that is not yet resolved | Run terraform init in the working directory |
Invalid expression naming a .tf file |
Configuration in the current directory does not parse | Fix the named file, or run the console elsewhere |
Reference to undeclared input variable |
Variable name typed wrongly, or no variable block for it |
Check the spelling against variables.tf |
Reference to undeclared resource |
Resource address does not exist in the root module | Confirm the type and name with terraform state list |
Unsupported attribute |
Attribute missing, or attribute access on a string or number | Read the detail line — it distinguishes the two cases |
Invalid index |
List index out of range, or map key absent | Check length() first, or use lookup() with a default |
Call to unknown function |
Function name misspelled or does not exist | Check the spelling against the function list |
Missing expression |
Unbalanced parentheses or truncated input | Close the expression before pressing Enter |
Result is (known after apply) |
Value is generated by the provider at create time | Apply first, or accept that it cannot be known early |
| Piped input prints only one value | Standard input is not a terminal | Run one expression per invocation |
| Piped input prints nothing | exit was the last line of the piped input |
Remove exit; end-of-file closes the console |
Clean up the lab
The console created nothing, but the apply in this walkthrough did. Remove both resources so the working directory is back where it started:
cd ~/terraform-labs/terraform-console/main && terraform destroy -auto-approve -no-colorterraform_data.demo: Refreshing state... [id=a1809f27-ad4b-9978-ec2e-8ef861ef67d3]
local_file.notes: Refreshing state... [id=1685b7a32904c8d97e4170def7199024e268bfb3]
Plan: 0 to add, 0 to change, 2 to destroy.
Changes to Outputs:
- resource_id = "a1809f27-ad4b-9978-ec2e-8ef861ef67d3" -> null
terraform_data.demo: Destroying... [id=a1809f27-ad4b-9978-ec2e-8ef861ef67d3]
terraform_data.demo: Destruction complete after 0s
local_file.notes: Destroying... [id=1685b7a32904c8d97e4170def7199024e268bfb3]
local_file.notes: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.The notes.txt file went with the local_file resource. Confirm the state is empty rather than assuming it:
terraform state list -no-colorAn empty state prints nothing at all and exits zero, which is the same silence you saw before anything existed. Terraform keeps terraform.tfstate as an empty shell and writes the previous contents to terraform.tfstate.backup.
References
- terraform console — command reference and flags
- Expressions — operators, conditionals, and
forexpressions - Functions — the full built-in function catalogue
- terraform_data resource — the built-in resource used in this lab
- State locking — how backends lock state during operations
Summary
The terraform console command turns expression debugging from a plan-and-wait cycle into an immediate answer. You opened a prompt, evaluated arithmetic, conditionals, for expressions, and a spread of string and collection functions, then moved on to the values that only exist inside your project: input variables, derived locals, and resource attributes read straight from state. Passing -var at startup let you change one input and watch every dependent local recompute, which is the closest thing Terraform has to a what-if calculator.
Two behaviours are worth carrying away because they look like bugs. Referencing a resource before you apply returns (known after apply) rather than an error, and in plain console mode the whole object reads that way even for attributes your configuration defines literally. Running with -plan separates the two cases properly: values Terraform derives from configuration resolve, while provider-generated identifiers stay unknown until creation. The other trap is piping several expressions at once, which silently prints only the last result. Outside an interactive session, keep to one expression per invocation.
Treat the console as a state-locking operation. HashiCorp documents that it holds a lock on state and that you cannot use it while another state-modifying operation runs, so on a shared backend do not leave a prompt open and expect a colleague's apply to go through. The lab in this guide happened to allow a concurrent apply on the local backend, which is a useful reminder that observed behaviour and the documented contract are not always the same thing, but the contract is what you should design your workflow around. One safety property does hold in every case: the console never writes state.
Open the console the next time an expression in your configuration produces something unexpected. Paste the expression at the prompt, compare what you get against what you assumed, and reach for -plan when the answer comes back as a placeholder rather than a value.

