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
nullablewith adefaultvalue - Not
nullablewithout adefaultvalue nullablewith adefaultvaluenullablewithout adefaultvalue
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
nullableisfalseand the variable has adefaultvalue, 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
resourceblocks 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
defaultif 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.