Introduction
When building reusable Terraform modules, you'll often face a design choice: should an input variable be required, optional with a default, or allowed to be null so that the provider's built-in defaults take precedence? Understanding Terraform null values and optional attributes explained is crucial for writing flexible modules that adapt to different deployment scenarios without forcing consumers to specify every possible configuration knob. In this article, we'll walk through the patterns that separate production-grade modules from brittle ones, covering optional() in object types with defaults, how null means "use the provider default," validating combinations of inputs, and why keeping object variables shallow matters.
optional() in object types with defaults
The optional() function, introduced in Terraform 1.3, lets you define attributes within object types that don't have to be supplied by the caller. This is cleaner than the older pattern of defining separate variables for every toggle.
Consider a module that provisions an AWS RDS database. You want to allow callers to customize backup settings, but you don't want to force them to specify every option.
variable "backup_config" {
type = object({
retention_days = optional(number, 7)
window = optional(string, "03:00-04:00")
copy_to_region = optional(string, null)
enable_backup_window = optional(bool, true)
})
default = {}
description = "RDS backup configuration"
}
This definition means:
retention_daysdefaults to 7 if not providedwindowdefaults to 03:00-04:00 UTC if not providedcopy_to_regiondefaults to null, which we'll use as a signal to skip cross-region copyingenable_backup_windowdefaults to true- The entire object defaults to empty, so callers can omit it entirely
When someone calls your module, they only need to override what they care about:
module "primary_db" {
source = "./modules/rds"
backup_config = {
retention_days = 30
copy_to_region = "eu-west-1"
}
}
The window and enable_backup_window fields automatically pick up their defaults. This approach scales well as your module grows; you don't end up with dozens of separate variables cluttering the interface.
null means use the provider default
A null value in Terraform is fundamentally different from an empty string, zero, or false. When you pass null to a resource argument, most providers interpret it as "I didn't specify this, so use your built-in default." This is powerful for staying compatible with future provider updates and for handling optional features cleanly.
Imagine you're writing a module for Google Cloud Storage buckets. Versioning is optional, and you want to let null mean "don't configure versioning at all."
variable "versioning_enabled" {
type = optional(bool)
default = null
description = "Enable object versioning. null = provider default (disabled)"
}
resource "google_storage_bucket" "data" {
name = var.bucket_name
dynamic "versioning" {
for_each = var.versioning_enabled != null ? [var.versioning_enabled] : []
content {
enabled = versioning.value
}
}
}
When versioning_enabled is null, the dynamic block produces zero blocks, so the resource's versioning setting is omitted entirely. The provider then uses its default (disabled). If a caller explicitly sets it to true or false, versioning is configured.
This pattern is especially useful in multi-environment scenarios. Your production module might want versioning on, but staging might leave it null to use whatever the provider says is standard. You avoid hardcoding opinions into your infrastructure code.
Validate combinations of inputs
Optional attributes give you flexibility, but they can also lead to invalid combinations. A module should validate that the configuration makes sense before attempting to create resources.
Consider a module that sets up a load balancer with optional SSL. You want to ensure that if the caller enables SSL, they must also provide a certificate ARN.
variable "tls_config" {
type = object({
enable_tls = optional(bool, false)
certificate_arn = optional(string, null)
})
default = {}
validation {
condition = (
var.tls_config.enable_tls == false ||
(var.tls_config.enable_tls == true && var.tls_config.certificate_arn != null)
)
error_message = "If enable_tls is true, certificate_arn must be provided."
}
}
Now if someone tries to enable TLS without a certificate, Terraform fails immediately with a clear error during planning, rather than failing during apply when the provider tries to create the resource.
Another example: you might want to ensure that if someone specifies custom database encryption, they also provide the key ID.
variable "encryption" {
type = object({
engine = optional(string, "default")
key_id = optional(string, null)
})
default = {}
validation {
condition = (
var.encryption.engine == "default" ||
(var.encryption.engine != "default" && var.encryption.key_id != null)
)
error_message = "Custom encryption engines require a key_id."
}
}
Validation blocks transform optional flexibility into guardrails. They document assumptions and prevent configuration drift caused by incomplete specifications.
Keep object variables shallow
One mistake teams make is nesting object types too deeply. A module's public interface—its variables—should be flat enough that callers can understand it at a glance.
Problematic approach:
variable "config" {
type = object({
database = object({
engine = object({
type = string
version = string
})
backup = object({
enabled = bool
retention = number
})
})
})
}
Callers have to write deeply nested blocks, and the structure is hard to parse visually.
Better approach—use multiple flat objects:
variable "database_engine" {
type = object({
type = string
version = string
})
default = {
type = "postgres"
version = "14"
}
}
variable "database_backup" {
type = object({
enabled = bool
retention = number
})
default = {
enabled = true
retention = 30
}
}
Now the module's variables are scannable, and callers can override just the parts they need. If you later want to add a new backup feature—say, cross-region replication—you add it to the database_backup object rather than creating a new top-level variable and breaking existing code.
A practical rule: keep your object types at most two levels deep. One level is ideal. If you need more structure, use separate variables and document how they work together with comments or a README.
Common mistakes
Mistake 1: Mixing null and empty strings as signals
Don't use null and empty string interchangeably. They mean different things. null = "not specified," empty string = "a string value that happens to be empty." Be consistent: pick one as your sentinel value and document it. Most teams prefer null because it's explicit about intent.
Mistake 2: Not validating interdependent fields
If you allow optional fields, someone will eventually combine them in a way you didn't anticipate. Always add validation blocks to document and enforce valid combinations. This saves debugging time later.
Mistake 3: Defaulting optional fields to values that differ from the provider's defaults
If Terraform's AWS provider defaults to enabled for something, don't default your optional field to false "just to be safe." This creates confusion and makes it hard to upgrade providers. Default to null, document the provider's behavior, and let callers opt in if they need to override.
Mistake 4: Making object types too deeply nested
If your variable structure needs more than one level of nesting, you're probably overcomplicating your module's interface. Split it into multiple flat objects or consider whether your module is trying to do too much.
Key takeaways
- Use
optional()with defaults in object types to create flexible module inputs without cluttering your variable list. - null is a signal that you want the provider's built-in default, not a placeholder for "undefined." Use dynamic blocks or conditionals to omit arguments when they're null.
- Validate combinations of optional inputs to catch mistakes early. Validation blocks serve as both guardrails and documentation.
- Keep object variables shallow—two levels of nesting at most. Multiple flat objects are easier for callers to read and modify than deeply nested structures.
- Document the difference between null, empty, and unspecified in your module's README or inline comments, especially when dealing with provider defaults.






Comments
Loading comments…