Terraform null values - replace null with a default (coalesce, try, conditional, optional(), nullable = false), with real output from Terraform 1.16

TL;DR

  • null in Terraform means "not set". Passing null to a resource argument is the same as omitting the argument; passing it into a string, a count or a for_each is an error.
  • Replace it with coalesce(var.port, 8080) when the fallback is a plain value, with var.name != null ? var.name : "app-default" when the fallback is an expression, with try(var.settings.owner, "nobody") when the attribute may not exist at all, and with optional(number, 1) inside object variables so callers can omit keys.
  • nullable = false on a variable makes null a 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 because coalesce treats the empty string as no value too; -var region=null on the CLI sets the string "null", not the null value; and a = 1; b = 2 is Invalid 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

  1. What null means in Terraform
  2. One file with every technique
  3. coalesce, try and the conditional - what each returns
  4. optional() in object variables
  5. nullable = false - reject null at plan time
  6. Null in resources, count and for-each loops
  7. What broke when I ran this
  8. 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:

ExpressionResult on 1.16.2Why
var.name == nulltruenull compares equal only to null
coalesce(null, 8080)8080first non-null, non-empty argument
coalesce(null, "")Error in function callempty string counts as "no value"
coalesce(null, null)Error in function callnothing left to return
tostring(null)tostring(null)null stays null, it is just typed
lookup({a="1"}, "b", null)nulla default can itself be null
try(null.x, "fallback")"fallback"try swallows the error
length(coalesce(var.tags, {}))0the 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.

Which Terraform construct to use for a value that may be null


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 : fallback is explicit and works for any type, including "". Both branches must have the same type, so var.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 coalesce cannot, because var.tags["env"] on a map without that key is an error, not a null. Do not use try as 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:

WhereNull meansTypical fix
resource argument (description = var.desc)argument omitted, provider default appliesnothing, this is the intended use
string interpolation "${var.name}-x"error: cannot interpolate nullcoalesce or the conditional
count = var.nerror: must be a whole numbercount = var.n != null ? var.n : 1
for_each = var.tagserror: must be a map or setfor_each = coalesce(var.tags, {})
optional() attribute without defaultattribute present with null valuetry(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:

CommandTime
terraform validate228 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 expression233 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:

  1. null is "unset", and a resource argument set to null is simply omitted.
  2. coalesce(var.x, default) for plain defaults, var.x != null ? var.x : default when the default is "" or an expression, try(var.obj.key, default) for keys that may not exist.
  3. optional(type, default) puts the default where every module caller can see it.
  4. nullable = false turns a null into a plan-time error, but only for real nulls, not the CLI string "null".
  5. 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 -