Before we begin
In this post, we’ll be exploring the use of the nullable
argument within Terraform variable
blocks.
My point of view is that nullable
is often overlooked or even actively disregarded. I’ve often seen this lead to unexpected results, even though handling nullable
is simple and requires little effort.
Prerequisites
To follow along, you’ll need:
- OpenTofu installed
Additionally a basic understanding of:
- Authoring and consuming reusable Published Modules
Objective
We will explore the parameter nullable
in variable
blocks and look into some edge cases (which you will find in a lot of modules).
Let’s Explore: nullable
Introduction: What does nullable
do?
The nullable
argument exists within the variable
block and determines if the value of the variable can be null
. It defaults to true
in both Terraform and OpenTofu. For more information, the OpenTofu documentation is a good resource.
Repository Setup
To keep this as simple as possible, we’ll create and reuse an OpenTofu module that contains four distinct variable
blocks:
- Not
nullable
with adefault
value - Not
nullable
without adefault
value nullable
with adefault
valuenullable
without adefault
value
We will use a simple folder structure to organize our example:
.
├── modules
│ └── nullable
│ └── main.tf
└── main.tf
In the example module’s modules/nullable/main.tf
file, we define four scenarios and output the result:
variable "not_nullable_with_default" {
default = "default"
nullable = false
type = string
}
variable "not_nullable_without_default" {
nullable = false
type = string
}
variable "nullable_with_default" {
default = "default"
nullable = true # This is the default
type = string
}
variable "nullable_without_default" {
nullable = true # This is the default
type = string
}
output "all" {
value = {
not_nullable_with_default = var.not_nullable_with_default
not_nullable_without_default = var.not_nullable_without_default
nullable_with_default = var.nullable_with_default
nullable_without_default = var.nullable_without_default
}
}
Calling the Module
Now, let’s call the module, explicitly setting all four input variables to null
:
module "basic_usage" {
source = "./modules/nullable"
not_nullable_with_default = null
not_nullable_without_default = null
nullable_with_default = null
nullable_without_default = null
}
output "basic_usage" {
value = module.basic_usage
}
Executing tofu plan
results in an error because the variable not_nullable_without_default
doesn’t have a default
value and explicitly disallows null
as input:
~ tofu plan
╷
│ Error: Required variable not set
│
│ on main.tf line 5, in module "basic_usage":
│ 5: not_nullable_without_default = null
│
│ The given value is not suitable for module.basic_usage.var.not_nullable_without_default defined at modules/simple/main.tf:7,1-40: required variable may not be set to null.
╵
Let’s resolve this by giving not_nullable_without_default
a value of module
:
...
not_nullable_with_default = null
not_nullable_without_default = "module"
nullable_with_default = null
nullable_without_default = null
# not_nullable_without_default = null
}
...
After the changes we now get a result:
~ tofu plan
Changes to Outputs:
+ basic_usage = {
+ all = {
+ not_nullable_with_default = "default"
+ not_nullable_without_default = "module"
+ nullable_with_default = null
+ nullable_without_default = null
}
}
You can apply this plan to save these new output values to the OpenTofu state, without changing any real infrastructure.
Not nullable
& with default
Why is this even possible? If the variable
is not nullable
, shouldn’t passing null
always fail validation?
The answer lies in a key piece of information from the documentation:
If
nullable
isfalse
and the variable has adefault
value, then OpenTofu uses the default when a module input argument isnull
.
This behavior is especially important to realize when looping over, for example, list
inputs with for_each
in a module
block. If not handled, unassigned or missing values will also send null
to the receiving module (we will see this later).
In resource
blocks, providing null
as a value generally omits the argument (behaves like it is not set):
resource "azurerm_resource_group" "this" {
name = "example"
location = null
}
is exactly the same as:
resource "azurerm_resource_group" "this" {
name = "example"
}
In the first example, the value for the location
argument is treated like it has not been defined (just as in the second example), which will lead to an error because location
is a required argument for the azurerm_resource_group
resource.
Not nullable
& without default
Even though this scenario initially caused an error, there’s nothing special about it. The rule is simply that the module consumer must provide a value other than null
for the argument.
nullable
& with default
Since the variable
allows null
and we assigned it null
, its value will simply be null
.
Consequently, any resources within the module that use this variable
as an input will omit those arguments.
nullable
& without default
Wait… does this mean a variable
that requires a value allows null
? Yes… null
is a value.
A simple variable
definition like:
variable "name" {
description = "Name of the resource."
type = string
}
per default allows null
to be provided, even though the variable
is required (no default
defined).
This is commonly used for resource definitions that strictly require names (e.g., Azure Resource Groups). This means the module consumer can provide null
, and a plan
might initially appear valid.
Note that a validation
block can also prevent the use of null
.
The “problem” with for_each
Let’s add a loop example to our root main.tf
:
# Loop example
variable "loop" {
type = map(object({
not_nullable_with_default = optional(string)
not_nullable_without_default = optional(string)
nullable_with_default = optional(string)
nullable_without_default = optional(string)
}))
}
module "loop" {
source = "./modules/nullable"
for_each = var.loop
not_nullable_with_default = each.value.not_nullable_with_default
not_nullable_without_default = each.value.not_nullable_without_default
nullable_with_default = each.value.nullable_with_default
nullable_without_default = each.value.nullable_without_default
}
output "loop" {
value = module.loop
}
And define our input map in terraform.tfvars
:
loop = {
"one" = {
not_nullable_with_default = "var"
not_nullable_without_default = "var"
nullable_with_default = "var"
nullable_without_default = "var"
},
"two" = {
not_nullable_without_default = "var"
}
}
The interesting input is now key "two"
. The “user” didn’t provide values for the non-required attributes. This absence of a value inside the object, combined with the use of optional
in the object type definition, is exactly the same as actively calling the module with null
.
Therefore, we’ll be sending null
for all unprovided values to the module, resulting in the following planned output for instance "two"
:
+ two = {
+ all = {
+ not_nullable_with_default = "default"
+ not_nullable_without_default = "var"
+ nullable_with_default = null
+ nullable_without_default = null
}
}
Takeaway: If the module doesn’t handle null
properly, this could lead to unexpected errors.
My Usage Pattern
For advanced checks on variable content (like format or length), the validation
block is the right choice.
Otherwise, my key thoughts narrow down to two questions:
- Do my
resource
blocks allow omitting a variable input?
A description
might be an optional argument to a resource
, while name
is likely always mandatory and shouldn’t be omitted. (This dictates the use of nullable = true
or nullable = false
).
- Do I want to always assign a
default
if the variable is not provided?
Expanding on the above: Maybe my module always wants a description
of "Managed by OpenTofu"
. This is achieved by setting the default
value in addition to nullable = false
.
This situation mostly occurs by accident when root modules use loops with for_each
and pass unassigned values, as shown in the previous example.
Closing
Thanks once again for stopping by! This one got way longer then I intended it to be but I hope you had fun reading.