Terraform null values - replace null with a default (coalesce, try, conditional, optional(), nullable = false), with real output from Terraform 1.16
TL;DR
nullin Terraform means "not set". Passingnullto a resource argument is the same as omitting the argument; passing it into a string, acountor afor_eachis an error.- Replace it with
coalesce(var.port, 8080)when the fallback is a plain value, withvar.name != null ? var.name : "app-default"when the fallback is an expression, withtry(var.settings.owner, "nobody")when the attribute may not exist at all, and withoptional(number, 1)insideobjectvariables so callers can omit keys. nullable = falseon a variable makesnulla plan-time error (Required variable not set), which is what you want for a region or an account ID.- Three traps I hit on 1.16.2:
coalesce(null, "")fails becausecoalescetreats the empty string as no value too;-var region=nullon the CLI sets the string"null", not the null value; anda = 1; b = 2isInvalid character, HCL has no semicolons. - Every output below is from Terraform v1.16.2 on Windows, run on 2026-10-17 with no provider and no cloud credentials, so you can copy the file and get the same results.
- Not your exact case? The closest post on this site is How to use Terraform locals?.
The 2023 version of this post explained what null is. For this rewrite I put every technique in one main.tf, applied it, and pushed the edge cases through terraform console, so the behaviour of each function is shown, not described.
Table of Content
- What null means in Terraform
- One file with every technique
- coalesce, try and the conditional - what each returns
- optional() in object variables
- nullable = false - reject null at plan time
- Null in resources, count and for-each loops
- What broke when I ran this
- Conclusion
1. What null means in Terraform
null is a value of every type that represents absence. It is not an empty string, not zero and not an empty list, and that distinction is where most of the confusion comes from:
| Expression | Result on 1.16.2 | Why |
|---|---|---|
var.name == null | true | null compares equal only to null |
coalesce(null, 8080) | 8080 | first non-null, non-empty argument |
coalesce(null, "") | Error in function call | empty string counts as "no value" |
coalesce(null, null) | Error in function call | nothing left to return |
tostring(null) | tostring(null) | null stays null, it is just typed |
lookup({a="1"}, "b", null) | null | a default can itself be null |
try(null.x, "fallback") | "fallback" | try swallows the error |
length(coalesce(var.tags, {})) | 0 | the usual way to iterate a map that may be null |
Pass null to a resource argument and the provider sees the argument as omitted, which is useful on purpose (description = var.desc where desc defaults to null). Pass it into "${var.name}-bucket", count = var.n or for_each = var.tags and the plan fails, which is why a default has to be substituted first. The conditional expressions page and the variables page describe the semantics; the rest of this post shows them.
2. One file with every technique
1variable "name" {
2 type = string
3 default = null
4}
5variable "port" {
6 type = number
7 default = null
8}
9variable "tags" {
10 type = map(string)
11 default = null
12}
13variable "region" {
14 type = string
15 nullable = false
16}
17variable "settings" {
18 type = object({
19 size = string
20 replicas = optional(number, 1)
21 owner = optional(string)
22 })
23 default = { size = "small" }
24}
25
26locals {
27 name_or_default = var.name != null ? var.name : "app-default"
28 port_coalesce = coalesce(var.port, 8080)
29 tags_safe = coalesce(var.tags, {})
30 owner_try = try(var.settings.owner, "nobody")
31}
32
33output "name_or_default" { value = local.name_or_default }
34output "port_coalesce" { value = local.port_coalesce }
35output "tags_safe" { value = local.tags_safe }
36output "settings" { value = var.settings }
37output "owner_try" { value = local.owner_try }
38output "is_null" { value = var.name == null }
39output "region" { value = var.region }
Applied with nothing set except the required region:
1$ terraform version
2Terraform v1.16.2
3
4$ terraform apply -auto-approve -var region=eu-central-1
5
6Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
7
8Outputs:
9
10is_null = true
11name_or_default = "app-default"
12port_coalesce = 8080
13region = "eu-central-1"
14settings = {
15 "owner" = tostring(null)
16 "replicas" = 1
17 "size" = "small"
18}
19tags_safe = tomap({})
Every null went where it should: name became app-default, port became 8080, the null map became an empty map, replicas took its optional() default of 1, and owner, which has optional(string) with no default, stayed null and is printed as tostring(null).
3. coalesce, try and the conditional - what each returns
The same expressions in terraform console, including the ones that fail:
1> coalesce(null, null, "third")
2"third"
3> try(var.tags["env"], "none")
4"none"
5> var.port != null ? var.port : 443
6443
7> lookup({a="1"}, "b", null)
8null
9> coalesce(null, "")
10
11Error: Error in function call
12> coalesce(null, null)
13
14Error: Error in function call
When to use which:
- coalesce takes any number of arguments and returns the first that is neither null nor empty string. Use it for simple defaults. Its blind spot is the empty string:
coalesce(var.suffix, "")to turn null into""fails; use the conditional for that. - The conditional
var.x != null ? var.x : fallbackis explicit and works for any type, including"". Both branches must have the same type, sovar.port != null ? var.port : "none"is an error (number versus string). - try returns the first argument that evaluates without an error. It handles a missing map key or object attribute, which
coalescecannot, becausevar.tags["env"]on a map without that key is an error, not a null. Do not usetryas a general default, it also hides typos in attribute names.
4. optional() in object variables
Before optional() every key of an object variable was mandatory, so a module caller had to write owner = null explicitly. With optional attributes the caller passes { size = "small" } and Terraform fills in the rest:
1variable "settings" {
2 type = object({
3 size = string # required
4 replicas = optional(number, 1) # omitted -> 1
5 owner = optional(string) # omitted -> null
6 })
7}
1> var.settings.replicas
21
3> var.settings.owner
4tostring(null)
optional(string) without a default leaves the attribute as null, so anything that uses var.settings.owner still needs one of the section 3 techniques. Put the default in the type constraint whenever a sensible one exists; it is the one place where the default is visible to every caller of the module.
5. nullable = false - reject null at plan time
For a value that must exist, do not substitute, refuse:
1variable "region" {
2 type = string
3 nullable = false
4}
1$ echo 'region = null' > null.auto.tfvars
2$ terraform plan
3
4Error: Required variable not set
The error comes before any provider is contacted. Note what does not trigger it:
1$ terraform plan -var region=null
2 ~ region = "eu-central-1" -> "null"
On the command line -var region=null is the four-letter string "null", because CLI values for string variables are taken literally. Only a .tfvars file or a TF_VAR_region with HCL-style parsing (for non-string types) produces the real null. If a module ends up with "null" as a region name, this is why.
6. Null in resources, count and for-each loops
The three places where null behaves differently from a default:
| Where | Null means | Typical fix |
|---|---|---|
resource argument (description = var.desc) | argument omitted, provider default applies | nothing, this is the intended use |
string interpolation "${var.name}-x" | error: cannot interpolate null | coalesce or the conditional |
count = var.n | error: must be a whole number | count = var.n != null ? var.n : 1 |
for_each = var.tags | error: must be a map or set | for_each = coalesce(var.tags, {}) |
optional() attribute without default | attribute present with null value | try(var.obj.key, default) |
In a module, the pattern I use is: accept null defaults in variables.tf so callers can omit things, convert every null exactly once in a locals block, and reference only the locals from resources. That keeps the substitution in one place, and Terraform locals explains why locals are the right layer for it. If the null comes from a data source that returned nothing, the fix is on the query side, not here.
7. What broke when I ran this
I ran this on Terraform v1.16.2 on Windows on 2026-10-17, with no provider block, so every error here is Terraform's own.
1. coalesce(null, "") is an error. I wanted a null to become an empty string for a tag value and reached for coalesce. It failed with Error in function call because coalesce discards empty strings as well as nulls, and with nothing left it has nothing to return. The conditional var.x != null ? var.x : "" does what I wanted.
2. -var region=null gave me the string "null". With nullable = false on the variable I expected terraform plan -var region=null to be rejected. Instead the plan output showed region = "eu-central-1" -> "null": a valid string value of four characters. Only region = null in a .tfvars file produced Required variable not set.
3. Semicolons. I wrote two locals on one line, a = 1; b = 2, out of habit from other languages:
1Error: Invalid character
2
3 on main.tf line 2, in locals:
4 2: a = 1; b = 2
5
6The ";" character is not valid. Use newlines to separate arguments and
7blocks, and commas to separate items in collection values.
HCL has no statement separator; one argument per line.
4. terraform console runs the whole configuration each call. I timed the checks on this laptop with no provider installed:
| Command | Time |
|---|---|
terraform validate | 228 ms |
terraform plan (all nulls substituted) | 212 ms |
terraform plan with region = null (rejected by nullable = false) | 187 ms |
terraform apply (outputs only) | 224 ms |
one terraform console expression | 233 ms |
Each console expression costs a full evaluation of variables and locals, about 230 ms, because Terraform loads the configuration every time. Fine for a dozen expressions, slow in a loop; for many checks put them in output blocks and run one terraform plan. The rejected plan is the cheapest of all at 187 ms, which is the point of nullable = false: it fails before anything else runs.
8. Conclusion
To summarise what I verified on Terraform 1.16.2:
nullis "unset", and a resource argument set to null is simply omitted.coalesce(var.x, default)for plain defaults,var.x != null ? var.x : defaultwhen the default is""or an expression,try(var.obj.key, default)for keys that may not exist.optional(type, default)puts the default where every module caller can see it.nullable = falseturns a null into a plan-time error, but only for real nulls, not the CLI string"null".- Convert nulls once, in
locals, and reference the locals.
If the value that is null comes from a variable file, Terraform variables and tfvars covers the loading order that decides which file wins, and string handling in Terraform covers what to do with the string once you have it.
Did this save you a debugging session? Stay in the loop -
- Subscribe on YouTube - every post here has a screen-recorded companion video.
- Code on GitHub - the repositories, Terraform and scripts behind these posts.