· Updated March 20, 2026 · 8 min read

Terraform Variable Validation & Preconditions Guide

terraformiacawsbest-practices
HAIT Cloud & DevOps Consulting

Validation vs Precondition vs Postcondition

FeatureValidationPreconditionPostcondition
WhereInside variable blockInside lifecycle blockInside lifecycle block
When it runsDuring plan, before any API callsDuring plan, after variable resolutionAfter apply
What it checksSingle variable value (format, length, range)Relationships between multiple variables or data sourcesResource attributes after creation
Use case”Is this a valid subnet ID?""Do we have enough subnets for the NAT config?""Did AWS return the expected attribute?”
Can referenceOnly self (the variable)Any variable, data source, or localResource attributes

If you’ve ever deployed a Terraform module only to discover that someone passed a private subnet ID where a public one was expected, you know the pain. The deployment “succeeds”, but nothing works. You spend 30 minutes debugging, only to realize the input was wrong from the start.

Terraform has tools to prevent this. Most people don’t use them.

Real-World Validation Failures I’ve Caught

After maintaining a dozen modules on the Terraform Registry, I keep a mental list of inputs that go wrong repeatedly. These are the validations that have saved me actual debugging time.

Overlapping or malformed CIDR blocks. Network inputs are the number one source of silent failures. I validate CIDR notation with can(cidrhost(var.cidr_block, 0)) - if Terraform can’t parse it, the variable is rejected at plan time. I also check that nobody passes a /32 for a subnet CIDR. A /32 is a single host address, not a subnet range, and Terraform will happily create a subnet with it. AWS won’t complain either - you’ll just end up with a subnet that can hold exactly zero usable IPs.

variable "cidr_block" {
  type = string

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "Must be a valid CIDR block (e.g., 10.0.1.0/24)."
  }

  validation {
    condition     = tonumber(split("/", var.cidr_block)[1]) <= 28
    error_message = "Subnet CIDR must be /28 or larger. /32 and /31 are not valid subnet ranges."
  }
}

Non-standard environment names. Every team has its own convention. Is it dev, development, or DEV? I enforce a strict set with validation so modules always get consistent values. This prevents tag mismatches, naming collisions, and conditional logic that breaks because someone passed staging instead of stg.

variable "environment" {
  type = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be one of: dev, staging, prod."
  }
}

Instance types that don’t exist in a region. This one uses a precondition with a data source. You can query the aws_ec2_instance_type data source and check if it returns results. If someone requests r6g.xlarge in a region that doesn’t have Graviton instances, the plan fails with a clear message instead of a cryptic AWS API error during apply.

data "aws_ec2_instance_type" "selected" {
  instance_type = var.instance_type
}

resource "aws_instance" "this" {
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = data.aws_ec2_instance_type.selected.id != ""
      error_message = "Instance type '${var.instance_type}' is not available in this region."
    }
  }
}

Security group rules with port 0. This is a common Terraform gotcha. In the AWS API, protocol -1 with from_port = 0 and to_port = 0 means “all traffic” - every port, every protocol. But people often set from_port = 0 when they mean port 0 specifically, or when they forget to set the port at all. I validate that if the protocol is tcp or udp, the port range must be explicitly set and non-zero.

validation {
  condition = !(
    contains(["tcp", "udp"], var.protocol) && var.from_port == 0
  )
  error_message = "Port 0 with TCP/UDP means ALL ports. Use protocol '-1' for all-traffic rules, or specify an actual port range."
}

The Problem: Silent Misconfiguration

Consider a simple NAT Gateway module:

variable "subnet_id" {
  description = "Subnet to place the NAT Gateway in"
  type        = string
}

resource "aws_nat_gateway" "this" {
  allocation_id = aws_eip.this.id
  subnet_id     = var.subnet_id
}

This accepts any subnet ID. Public, private, doesn’t matter. Terraform won’t complain. AWS won’t complain (immediately). But your private subnets won’t have internet access, and you’ll spend time figuring out why.

The Fix: Validation Blocks

Since Terraform 1.0, you can add validation blocks to variables:

variable "public_subnet_ids" {
  description = "Public subnet IDs for NAT Gateway placement"
  type        = list(string)

  validation {
    condition     = length(var.public_subnet_ids) > 0
    error_message = "At least one public subnet ID is required."
  }

  validation {
    condition     = alltrue([for id in var.public_subnet_ids : startswith(id, "subnet-")])
    error_message = "All values must be valid subnet IDs (starting with 'subnet-')."
  }
}

Now terraform plan fails immediately with a clear message if someone passes an empty list or garbage values.

Going Further: Preconditions

For validations that need to check relationships between variables, use precondition blocks in lifecycle:

resource "aws_nat_gateway" "this" {
  count = var.single_nat_gateway ? 1 : length(var.public_subnet_ids)

  allocation_id = aws_eip.this[count.index].id
  subnet_id     = var.public_subnet_ids[count.index]

  lifecycle {
    precondition {
      condition     = var.single_nat_gateway || length(var.public_subnet_ids) >= length(var.private_route_table_ids)
      error_message = "When using multi-AZ NAT, you need at least as many public subnets as private route tables."
    }
  }
}

This catches architectural mistakes at plan time, not after a 10-minute apply.

What I Validate in Every Module

After building 12 Terraform modules for AWS, here’s my checklist:

WhatWhy
Non-empty required listsPrevents silent no-ops
ID format (subnet-, vpc-, sg-)Catches copy-paste errors
CIDR block formatRegex validation on network inputs
Mutually exclusive flagse.g., single_nat_gateway vs per-AZ mode
Cross-variable consistencyPreconditions on resource blocks

The Payoff

Every validation you add is one fewer support ticket, one fewer “why isn’t this working” Slack message, and one fewer hour lost to debugging obvious misconfigurations.

The best part: these validations run during terraform plan. Zero cost. Zero risk. Just faster feedback.

Validation in CI/CD Pipelines

Validation blocks run automatically during terraform plan, but that only helps if plan runs before code reaches production. The right place for this is your CI pipeline, triggered on every pull request.

The simplest setup is a pre-commit hook using the pre-commit-terraform framework. Add terraform_validate to your .pre-commit-config.yaml and every commit runs validation locally before it even gets pushed. This catches the obvious mistakes - typos, missing required variables, format violations - before they enter the PR review cycle.

In GitHub Actions, the step is straightforward:

- name: Terraform Validate
  run: |
    terraform init -backend=false
    terraform validate

The -backend=false flag skips backend configuration, so you don’t need cloud credentials just to run validation. This step takes seconds and catches every validation and precondition block in your module. If it fails, the PR gets a red check and the author sees the exact error message you wrote in the validation block.

The point of this is shifting the feedback loop left. A validation failure in CI takes 30 seconds to notice and fix. The same misconfiguration caught after a 15-minute apply in a staging environment costs an order of magnitude more time - especially if other resources have already been created and need to be cleaned up. Every validation block you add to a module becomes an automated check that runs on every PR, for every consumer of that module, forever.

Frequently Asked Questions

What is a Terraform validation block?

A validation block inside a variable definition lets you define conditions that inputs must satisfy. If the condition fails, terraform plan exits immediately with your custom error message - before any resources are created or modified.

What is the difference between validation and precondition?

Validation blocks check individual variable values in isolation (format, length, prefix). Precondition blocks sit inside resource lifecycle blocks and can check relationships between multiple variables or data sources - for example, ensuring the number of subnets matches the number of route tables.

When should I use postconditions?

Use postconditions when you need to verify something after a resource is created - for example, checking that an AWS resource returned an expected attribute value. Postconditions run after apply, not during plan.

Do validation blocks slow down terraform plan?

No. Validation blocks add negligible overhead. They run as simple in-memory checks before any API calls, so there is zero cost and zero risk to adding them.


Building Terraform modules for AWS? Check out the HAIT module collection on the Terraform Registry.