Validation and Constraints

6 minute read

Validation Overview

Validation is one of the most important aspects of provider development. Good validation catches configuration errors during terraform plan, not during terraform apply when it’s too late.

In this chapter, you’ll learn how to implement comprehensive validation using the Plugin Framework’s validator system.

Why Validate?

Benefits of validation:

  • ✅ Early error detection — Catch problems at plan time
  • ✅ Better error messages — Guide users to correct configuration
  • ✅ Prevent API errors — Don’t waste API calls on invalid input
  • ✅ Type safety — Enforce business rules beyond basic types
  • ✅ User experience — Fast feedback loop

Without validation:

$ terraform apply
...
Error: API returned 400: Invalid port number 99999

With validation:

$ terraform plan
...
Error: Invalid Attribute Value
  
Port must be between 1 and 65535, got 99999

Built-in Validators

The Plugin Framework provides validators for all common scenarios.

String Validators

import (
    "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
    "github.com/hashicorp/terraform-plugin-framework/schema/validator"
)

"region": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        // One of specific values
        stringvalidator.OneOf("us-east-1", "us-west-2", "eu-west-1"),
    },
},

Available string validators:

OneOf

stringvalidator.OneOf("value1", "value2", "value3")

NoneOf

stringvalidator.NoneOf("forbidden1", "forbidden2")

LengthBetween

stringvalidator.LengthBetween(1, 255)

LengthAtLeast

stringvalidator.LengthAtLeast(1)

LengthAtMost

stringvalidator.LengthAtMost(100)

RegexMatches

stringvalidator.RegexMatches(
    regexp.MustCompile(`^[a-z][a-z0-9-]*$`),
    "must start with letter and contain only lowercase letters, numbers, and hyphens",
)

UTF8LengthBetween

stringvalidator.UTF8LengthBetween(1, 100)

ConflictsWith

stringvalidator.ConflictsWith(path.Expressions{
    path.MatchRoot("other_field"),
}...)

AlsoRequires

stringvalidator.AlsoRequires(path.Expressions{
    path.MatchRoot("required_field"),
}...)

Number Validators

import "github.com/hashicorp/terraform-plugin-framework-validators/numbervalidator"

"port": schema.NumberAttribute{
    Required: true,
    Validators: []validator.Number{
        numbervalidator.Between(1, 65535),
    },
},

Available number validators:

Between

numbervalidator.Between(1, 100)

AtLeast

numbervalidator.AtLeast(0)

AtMost

numbervalidator.AtMost(1000)

OneOf

numbervalidator.OneOf(
    big.NewFloat(1),
    big.NewFloat(2),
    big.NewFloat(4),
    big.NewFloat(8),
)

NoneOf

numbervalidator.NoneOf(
    big.NewFloat(0),
    big.NewFloat(-1),
)

Bool Validators

import "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator"

"enabled": schema.BoolAttribute{
    Required: true,
    Validators: []validator.Bool{
        // Example: Conflicts with another field
        boolvalidator.ConflictsWith(path.Expressions{
            path.MatchRoot("disabled"),
        }...),
    },
},

Available bool validators:

ConflictsWith

boolvalidator.ConflictsWith(path.Expressions{...}...)

AlsoRequires

boolvalidator.AlsoRequires(path.Expressions{...}...)

List Validators

import "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"

"availability_zones": schema.ListAttribute{
    ElementType: types.StringType,
    Required:    true,
    Validators: []validator.List{
        listvalidator.SizeAtLeast(1),
        listvalidator.SizeAtMost(3),
        listvalidator.UniqueValues(),
    },
},

Available list validators:

SizeBetween

listvalidator.SizeBetween(1, 10)

SizeAtLeast

listvalidator.SizeAtLeast(1)

SizeAtMost

listvalidator.SizeAtMost(5)

UniqueValues

listvalidator.UniqueValues()

ValueStringsAre (nested validation)

listvalidator.ValueStringsAre(
    stringvalidator.LengthAtLeast(1),
)

Map Validators

import "github.com/hashicorp/terraform-plugin-framework-validators/mapvalidator"

"labels": schema.MapAttribute{
    ElementType: types.StringType,
    Optional:    true,
    Validators: []validator.Map{
        mapvalidator.SizeAtMost(50),
        mapvalidator.KeysAre(
            stringvalidator.LengthBetween(1, 63),
        ),
    },
},

Available map validators:

SizeBetween

mapvalidator.SizeBetween(1, 100)

SizeAtLeast

mapvalidator.SizeAtLeast(1)

SizeAtMost

mapvalidator.SizeAtMost(50)

KeysAre (nested validation)

mapvalidator.KeysAre(
    stringvalidator.RegexMatches(
        regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]*$`),
        "must start with letter",
    ),
)

ValueStringsAre (nested validation)

mapvalidator.ValueStringsAre(
    stringvalidator.LengthAtMost(256),
)

Set Validators

import "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator"

"security_groups": schema.SetAttribute{
    ElementType: types.StringType,
    Required:    true,
    Validators: []validator.Set{
        setvalidator.SizeAtLeast(1),
        setvalidator.ValueStringsAre(
            stringvalidator.RegexMatches(
                regexp.MustCompile(`^sg-[a-z0-9]+$`),
                "must be valid security group ID",
            ),
        ),
    },
},

Available set validators:

SizeBetween

setvalidator.SizeBetween(1, 10)

SizeAtLeast

setvalidator.SizeAtLeast(1)

SizeAtMost

setvalidator.SizeAtMost(5)

ValueStringsAre (nested validation)

setvalidator.ValueStringsAre(
    stringvalidator.LengthAtLeast(1),
)

Multiple Validators

Combine multiple validators on a single attribute:

"name": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        stringvalidator.LengthBetween(3, 64),
        stringvalidator.RegexMatches(
            regexp.MustCompile(`^[a-z][a-z0-9-]*$`),
            "must start with lowercase letter and contain only lowercase letters, numbers, and hyphens",
        ),
    },
},

All validators must pass.

Custom Validators

Create custom validators for business logic.

String Validator Example

package validators

import (
    "context"
    "fmt"
    "strings"
    
    "github.com/hashicorp/terraform-plugin-framework/schema/validator"
)

type noWhitespaceValidator struct{}

func NoWhitespace() validator.String {
    return noWhitespaceValidator{}
}

func (v noWhitespaceValidator) Description(ctx context.Context) string {
    return "value must not contain whitespace"
}

func (v noWhitespaceValidator) MarkdownDescription(ctx context.Context) string {
    return "value must not contain whitespace"
}

func (v noWhitespaceValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    if strings.Contains(value, " ") || strings.Contains(value, "\t") || strings.Contains(value, "\n") {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid Value",
            fmt.Sprintf("Value must not contain whitespace, got: %q", value),
        )
    }
}

Usage:

"username": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        validators.NoWhitespace(),
        stringvalidator.LengthBetween(3, 32),
    },
},

Number Validator Example

type portRangeValidator struct{}

func PortRange() validator.Number {
    return portRangeValidator{}
}

func (v portRangeValidator) Description(ctx context.Context) string {
    return "value must be a valid port number (1-65535)"
}

func (v portRangeValidator) MarkdownDescription(ctx context.Context) string {
    return "value must be a valid port number (1-65535)"
}

func (v portRangeValidator) ValidateNumber(ctx context.Context, req validator.NumberRequest, resp *validator.NumberResponse) {
    if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
        return
    }
    
    value := req.ConfigValue.ValueBigFloat()
    port, _ := value.Int64()
    
    if port < 1 || port > 65535 {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid Port Number",
            fmt.Sprintf("Port must be between 1 and 65535, got: %d", port),
        )
    }
}

Cross-Attribute Validation

Validate relationships between multiple attributes.

Using Built-in Validators

ConflictsWith

"password": schema.StringAttribute{
    Optional:  true,
    Sensitive: true,
    Validators: []validator.String{
        stringvalidator.ConflictsWith(path.Expressions{
            path.MatchRoot("password_hash"),
        }...),
    },
},

"password_hash": schema.StringAttribute{
    Optional:  true,
    Sensitive: true,
    Validators: []validator.String{
        stringvalidator.ConflictsWith(path.Expressions{
            path.MatchRoot("password"),
        }...),
    },
},

AlsoRequires

"vpc_id": schema.StringAttribute{
    Optional: true,
    Validators: []validator.String{
        stringvalidator.AlsoRequires(path.Expressions{
            path.MatchRoot("subnet_id"),
        }...),
    },
},

"subnet_id": schema.StringAttribute{
    Optional: true,
},

AtLeastOneOf

"public_key": schema.StringAttribute{
    Optional: true,
    Validators: []validator.String{
        stringvalidator.AtLeastOneOf(path.Expressions{
            path.MatchRoot("public_key"),
            path.MatchRoot("public_key_path"),
        }...),
    },
},

"public_key_path": schema.StringAttribute{
    Optional: true,
    Validators: []validator.String{
        stringvalidator.AtLeastOneOf(path.Expressions{
            path.MatchRoot("public_key"),
            path.MatchRoot("public_key_path"),
        }...),
    },
},

Custom Cross-Attribute Validation

For complex logic, use plan validators:

func (r *MyResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
    var config MyResourceModel
    
    resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Custom validation logic
    if !config.MinReplicas.IsNull() && !config.MaxReplicas.IsNull() {
        minVal, _ := config.MinReplicas.ValueBigFloat().Int64()
        maxVal, _ := config.MaxReplicas.ValueBigFloat().Int64()
        
        if minVal > maxVal {
            resp.Diagnostics.AddError(
                "Invalid Configuration",
                fmt.Sprintf("min_replicas (%d) must be less than or equal to max_replicas (%d)", minVal, maxVal),
            )
        }
    }
}

Validation Best Practices

1. Validate Early and Often

// ✅ Good: Validate in schema
"port": schema.NumberAttribute{
    Required: true,
    Validators: []validator.Number{
        numbervalidator.Between(1, 65535),
    },
},

// ❌ Bad: Validate in Create
func (r *MyResource) Create(...) {
    // Too late! Should be in schema
    if port < 1 || port > 65535 {
        return errors.New("invalid port")
    }
}

2. Provide Clear Error Messages

// ✅ Good: Clear message
stringvalidator.RegexMatches(
    regexp.MustCompile(`^[a-z][a-z0-9-]*$`),
    "must start with lowercase letter and contain only lowercase letters, numbers, and hyphens",
)

// ❌ Bad: Vague message
stringvalidator.RegexMatches(
    regexp.MustCompile(`^[a-z][a-z0-9-]*$`),
    "invalid format",
)

3. Validate All Constraints

// ✅ Good: Complete validation
"name": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        stringvalidator.LengthBetween(1, 255),  // Length constraint
        stringvalidator.RegexMatches(...),       // Format constraint
        validators.NoWhitespace(),               // Business rule
    },
},

4. Document Validation Rules

"name": schema.StringAttribute{
    Required: true,
    MarkdownDescription: "Environment name. Must be 1-255 characters, start with lowercase letter, and contain only lowercase letters, numbers, and hyphens.",
    Validators: []validator.String{
        stringvalidator.LengthBetween(1, 255),
        stringvalidator.RegexMatches(
            regexp.MustCompile(`^[a-z][a-z0-9-]*$`),
            "must start with lowercase letter and contain only lowercase letters, numbers, and hyphens",
        ),
    },
},

Common Validation Patterns

Pattern 1: Enum Values

"instance_type": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        stringvalidator.OneOf("t2.micro", "t2.small", "t2.medium", "t2.large"),
    },
},

Pattern 2: Resource Identifier Format

"vpc_id": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        stringvalidator.RegexMatches(
            regexp.MustCompile(`^vpc-[a-z0-9]+$`),
            "must be valid VPC ID (e.g., vpc-abc123)",
        ),
    },
},

Pattern 3: CIDR Blocks

"cidr_block": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        stringvalidator.RegexMatches(
            regexp.MustCompile(`^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$`),
            "must be valid CIDR block (e.g., 10.0.0.0/16)",
        ),
    },
},

Pattern 4: Email Addresses

"email": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        stringvalidator.RegexMatches(
            regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`),
            "must be valid email address",
        ),
    },
},

Pattern 5: URL Validation

"endpoint": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        stringvalidator.RegexMatches(
            regexp.MustCompile(`^https?://`),
            "must be valid HTTP or HTTPS URL",
        ),
    },
},

Pattern 6: Range Validation

"cpu_percentage": schema.NumberAttribute{
    Required: true,
    Validators: []validator.Number{
        numbervalidator.Between(0, 100),
    },
},

Pattern 7: Mutually Exclusive Fields

"source_ami": schema.StringAttribute{
    Optional: true,
    Validators: []validator.String{
        stringvalidator.ExactlyOneOf(path.Expressions{
            path.MatchRoot("source_ami"),
            path.MatchRoot("source_snapshot"),
        }...),
    },
},

"source_snapshot": schema.StringAttribute{
    Optional: true,
    Validators: []validator.String{
        stringvalidator.ExactlyOneOf(path.Expressions{
            path.MatchRoot("source_ami"),
            path.MatchRoot("source_snapshot"),
        }...),
    },
},

Error Messages

Good Error Message Structure

Error: Invalid Attribute Value

  on main.tf line 10, in resource "mycloud_environment" "example":
  10:   port = 99999

Port must be between 1 and 65535, got 99999.

Components:

  1. Error type
  2. Location (file, line, resource)
  3. Invalid value
  4. Clear explanation of constraint
  5. Actual value received

Error Message Best Practices

✅ Good:

  • “Port must be between 1 and 65535, got 99999”
  • “Name must start with lowercase letter and contain only lowercase letters, numbers, and hyphens”
  • “Either public_key or public_key_path must be specified, but not both”

❌ Bad:

  • “Invalid value”
  • “Wrong format”
  • “Error”

Key Takeaways

  • ✅ Validate at plan time, not apply time
  • ✅ Use built-in validators when possible
  • ✅ Create custom validators for business logic
  • ✅ Validate cross-attribute constraints
  • ✅ Provide clear, actionable error messages
  • ✅ Document validation rules in schema descriptions
  • ✅ Test validators thoroughly

What’s Next?

Now let’s learn about handling sensitive attributes.

Next: Sensitive Attributes →


Previous: ← Required, Optional, and Computed

Last updated: June 30, 2026

Updated: