Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 084 / 100

Terraform null values and optional attributes explained

Flexible module inputs.

Expona Admin7 min read

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.

hcl
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_days defaults to 7 if not provided
  • window defaults to 03:00-04:00 UTC if not provided
  • copy_to_region defaults to null, which we'll use as a signal to skip cross-region copying
  • enable_backup_window defaults 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:

hcl
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."

hcl
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.

hcl
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.

hcl
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:

hcl
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:

hcl
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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles