Introduction
We've all pushed a Terraform change only to watch it fail halfway through applying, discovering too late that a variable combination was invalid or a prerequisite wasn't met. Validating inputs with preconditions in Terraform & IaC shifts that feedback loop left, catching configuration mistakes before resources ever get provisioned. Preconditions let us assert that input states are correct, while postconditions verify that outputs match our expectations—turning silent failures into explicit, actionable errors. In teams managing dozens of environments across multiple cloud accounts, this shift-left approach has prevented countless production incidents and reduced deployment friction significantly.
Understanding precondition and postcondition blocks
Terraform 1.2+ introduced preconditions and postconditions as first-class constructs for validation. A precondition runs before a resource is created or updated, checking that required conditions are true. A postcondition runs after a resource is created, verifying that the result matches our expectations. Both accept a condition expression that must evaluate to true and an error_message that users see when validation fails.
Preconditions work on resources, data sources, and outputs. They're the gatekeepers preventing invalid configurations from reaching the cloud provider. The timing matters: preconditions evaluate during the planning phase, so invalid configs are caught before any API calls happen. This means faster feedback and no cloud provider charges for failed operations.
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
precondition {
condition = can(regex("^ami-", var.ami_id))
error_message = "AMI ID must start with 'ami-'."
}
precondition {
condition = contains(["t3.micro", "t3.small", "t3.medium"], var.instance_type)
error_message = "Instance type must be t3.micro, t3.small, or t3.medium."
}
}If we try to apply with instance_type = "t2.large", Terraform stops immediately with the second error message, never touching AWS. This happens during plan, so the operator sees the problem before committing. Postconditions work similarly but validate the result after creation:
resource "aws_db_instance" "postgres" {
allocated_storage = var.storage_gb
engine = "postgres"
instance_class = var.db_instance_class
db_name = var.database_name
postcondition {
condition = self.availability_zone != null
error_message = "Database must be placed in a valid availability zone."
}
postcondition {
condition = self.storage_encrypted == true || var.allow_unencrypted
error_message = "Database storage must be encrypted unless explicitly allowed via allow_unencrypted variable."
}
}Postconditions are particularly useful when provider behavior is unpredictable or when you need to enforce guardrails after a resource exists. For instance, some AWS resources might fail to encrypt silently if an encryption key is unavailable—a postcondition catches this.
Checking relationships between inputs
Real-world infrastructure has interdependencies. A staging environment shouldn't use production backup windows; a small database shouldn't be in a multi-AZ setup that costs extra for minimal benefit. Preconditions excel at enforcing these logical relationships before we waste time on a failed apply.
Consider a scenario where we're provisioning RDS with encryption and backups. Multiple teams have accidentally created production databases without encryption or proper backup retention. A precondition prevents this:
variable "environment" {
type = string
}
variable "enable_encryption" {
type = bool
}
variable "backup_retention_days" {
type = number
}
variable "multi_az" {
type = bool
}
variable "instance_class" {
type = string
}
resource "aws_db_instance" "main" {
allocated_storage = 20
engine = "mysql"
instance_class = var.instance_class
username = "admin"
password = random_password.db.result
storage_encrypted = var.enable_encryption
backup_retention_period = var.backup_retention_days
multi_az = var.multi_az
precondition {
condition = var.environment == "prod" ? var.enable_encryption : true
error_message = "Production databases must have encryption enabled."
}
precondition {
condition = var.environment == "prod" ? var.backup_retention_days >= 7 : true
error_message = "Production databases must retain backups for at least 7 days. You specified ${var.backup_retention_days} days."
}
precondition {
condition = var.backup_retention_days <= 35
error_message = "Backup retention cannot exceed 35 days (AWS limit). You specified ${var.backup_retention_days} days."
}
precondition {
condition = var.environment == "prod" ? var.multi_az : true
error_message = "Production databases must have multi-AZ enabled for high availability."
}
precondition {
condition = var.multi_az ? var.instance_class != "db.t3.micro" : true
error_message = "Multi-AZ deployments require at least db.t3.small. Micro instances do not support multi-AZ."
}
precondition {
condition = var.environment == "dev" ? var.backup_retention_days <= 7 : true
error_message = "Development databases should keep backups for 7 days or less to control costs. You specified ${var.backup_retention_days} days."
}
}Now if someone accidentally deploys to production without encryption, with a 2-day backup window, or tries multi-AZ on a micro instance, Terraform catches it before the database is created. The relationships between environment, encryption, instance class, and multi-AZ are explicit and enforced consistently. In a real production system, we've seen this pattern prevent accidental cost overruns and security violations monthly.
Clear error messages
A validation failure is only useful if it tells the user what went wrong and how to fix it. Generic "condition failed" messages leave the team guessing and often lead to Slack channels full of confused engineers. Good error messages are specific, actionable, and sometimes include hints about what was provided and what's expected.
variable "cidr_block" {
type = string
}
variable "enable_dns_hostnames" {
type = bool
}
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
enable_dns_hostnames = var.enable_dns_hostnames
precondition {
condition = can(cidrhost(var.cidr_block, 0))
error_message = "CIDR block '${var.cidr_block}' is not a valid IPv4 CIDR. Example: '10.0.0.0/16'. Ensure you're using proper CIDR notation with subnet mask."
}
precondition {
condition = tonumber(split("/", var.cidr_block)[1]) >= 16
error_message = "VPC CIDR must be /16 or larger (e.g., /16, /17, /18) to allow subnetting and future growth. You provided ${var.cidr_block}, which is too restrictive."
}
precondition {
condition = var.enable_dns_hostnames == true
error_message = "DNS hostnames must be enabled for resources to receive public DNS names. Set enable_dns_hostnames to true."
}
}When validation fails, users see exactly why and what they should change. Include the actual value they passed in the message—it cuts debugging time dramatically. Use template strings with ${} to show the problematic value alongside suggestions. This is especially valuable in CI/CD pipelines where the operator isn't sitting at a terminal; they're reading log output hours later.
Combining with variable validation
Preconditions and variable validation rules serve different purposes. variable validation rules catch individual input problems early in parsing. Preconditions catch complex, cross-variable relationships at the resource level. Use both strategically.
variable "replica_count" {
type = number
validation {
condition = var.replica_count >= 1 && var.replica_count <= 10
error_message = "Replica count must be between 1 and 10."
}
}
variable "instance_type" {
type = string
validation {
condition = can(regex("^[a-z][a-z0-9]*\\.[a-z0-9]+$", var.instance_type))
error_message = "Instance type must match AWS naming (e.g., 't3.micro')."
}
}
variable "automatic_failover" {
type = bool
}
resource "aws_elasticache_cluster" "redis" {
cluster_id = "my-redis"
engine = "redis"
node_type = var.instance_type
num_cache_nodes = var.replica_count
parameter_group_name = "default.redis7"
automatic_failover_enabled = var.automatic_failover
precondition {
condition = var.replica_count > 1 ? var.instance_type != "cache.t3.micro" : true
error_message = "Multi-node clusters (replica_count=${var.replica_count}) cannot use cache.t3.micro. Upgrade to cache.t3.small or larger, or reduce replica_count to 1."
}
precondition {
condition = var.automatic_failover ? var.replica_count > 1 : true
error_message = "Automatic failover requires at least 2 replicas. You have replica_count=${var.replica_count}. Either increase replicas or disable failover."
}
precondition {
condition = var.automatic_failover ? var.instance_type != "cache.t3.micro" : true
error_message = "Automatic failover is not supported on cache.t3.micro instances. Use cache.t3.small or larger."
}
}Here, variable validation ensures replica_count is a valid number and instance_type follows AWS naming. The preconditions add cross-variable rules: if you want multiple replicas, you need a larger instance type; if you enable automatic failover, you need sufficient replicas and a compatible instance. Layering these catches more mistakes with clearer diagnostics. Teams often discover these relationships only after a production incident—preconditions codify that tribal knowledge into enforcement.
Handling edge cases and provider surprises
Preconditions work well for logical validation, but postconditions catch provider quirks. Some resources behave unexpectedly: an S3 bucket might not have versioning enabled if the account has a service control policy blocking it, or an RDS instance might end up in an unexpected AZ due to capacity constraints.
resource "aws_s3_bucket" "data" {
bucket = var.bucket_name
postcondition {
condition = aws_s3_bucket_versioning.data.versioning_configuration[0].status == "Enabled"
error_message = "Bucket versioning must be enabled after creation. If this fails, check for SCPs or permissions blocking versioning configuration."
}
}
resource "aws_s3_bucket_versioning" "data" {
bucket = aws_s3_bucket.data.id
versioning_configuration {
status = "Enabled"
}
}In rare cases, the provider might report success but the resource isn't configured as expected. Postconditions catch these anomalies and fail fast rather than letting misconfigured infrastructure slip into production.
Common mistakes
- Overly complex conditions: A precondition with nested ternaries, multiple function calls, and nested boolean logic becomes hard to test and debug. Keep conditions simple; extract repeated logic into local variables first. If your condition spans more than one line, consider refactoring.
- Missing error messages: Always provide
error_message. A bare condition that fails is frustrating; a condition with context including the actual value is solvable. Never rely on the default error output. - Validating only inputs, never outputs: Postconditions catch provider surprises (e.g., a resource created in an unexpected AZ, encryption silently disabled). Use both directions to build comprehensive guardrails.
- Preconditions on data sources without understanding timing: Preconditions on data sources run at planning time; if the external state changes between plan and apply, postconditions won't catch it on data sources because data sources are re-fetched during apply. Document this assumption in your code.
- Assuming preconditions prevent all errors: They catch validation logic, not provider limits or account permissions. A precondition might allow 100 ENIs, but AWS account limits might reject it at apply time. Preconditions are one layer, not a guarantee.
- Testing conditions only on the happy path: Always test your conditions with invalid inputs. A condition that's too permissive or too restrictive only becomes apparent under edge cases.
- Hard-coding values instead of using variables: If you precondition on a static list (like allowed instance types), use a variable or local value so operators can see what's allowed without reading the code.
Key takeaways
- Preconditions and postconditions let you validate infrastructure assumptions before resources are created, shifting feedback left and reducing rollback risk and cloud provider costs.
- Use preconditions to enforce relationships between variables (e.g., prod must have encryption, multi-replica clusters need larger instances), catching whole categories of configuration mistakes at plan time.
- Write specific, actionable error messages that include the problematic value and hints on how to fix it. Templates with
${}expressions are your friends. - Combine variable validation rules with resource preconditions: rules catch individual input format issues during variable parsing, preconditions catch cross-variable logic at the resource level.
- Test your conditions thoroughly in a non-production environment with both valid and invalid inputs; bad conditions can block legitimate configurations or miss real problems.
- Use postconditions for provider surprises and verification that resources are configured as expected after creation; don't rely only on preconditions to guarantee state.
- Document why each precondition exists—link to incidents, compliance requirements, or cost considerations so future maintainers understand the intent.






Comments
Loading comments…