Schema-Level Validation

7 minute read

Schema Validation Overview

Schema-level validators validate individual attributes during Terraform’s plan phase, providing early error detection before API calls.

Built-in Validators

The Terraform Plugin Framework provides validators for common scenarios.

String Validators

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

func (r *CacheResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) {
    resp.Schema = schema.Schema{
        Attributes: map[string]schema.Attribute{
            "engine": schema.StringAttribute{
                Required:    true,
                Description: "Cache engine type",
                Validators: []validator.String{
                    stringvalidator.OneOf("redis", "memcached"),
                },
            },
            "name": schema.StringAttribute{
                Required:    true,
                Description: "Cache name",
                Validators: []validator.String{
                    stringvalidator.LengthBetween(3, 63),
                    stringvalidator.RegexMatches(
                        regexp.MustCompile(`^[a-z][a-z0-9-]*$`),
                        "must start with lowercase letter and contain only lowercase letters, numbers, and hyphens",
                    ),
                },
            },
            "description": schema.StringAttribute{
                Optional:    true,
                Description: "Cache description",
                Validators: []validator.String{
                    stringvalidator.LengthAtMost(500),
                },
            },
        },
    }
}

Common String Validators

// Exact matches
stringvalidator.OneOf("value1", "value2", "value3")

// Length constraints
stringvalidator.LengthAtLeast(1)
stringvalidator.LengthAtMost(100)
stringvalidator.LengthBetween(5, 50)

// Pattern matching
stringvalidator.RegexMatches(
    regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`),
    "must start with uppercase letter",
)

// Conflict with other attributes
stringvalidator.ConflictsWith(path.MatchRoot("other_attr"))

// Require other attributes
stringvalidator.AlsoRequires(path.MatchRoot("required_attr"))

// Mutually exclusive
stringvalidator.ExactlyOneOf(
    path.MatchRoot("option_a"),
    path.MatchRoot("option_b"),
)

Number Validators

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

"port": schema.Int64Attribute{
    Optional:    true,
    Description: "Cache port number",
    Validators: []validator.Int64{
        int64validator.Between(1024, 65535),
    },
},
"num_nodes": schema.Int64Attribute{
    Required:    true,
    Description: "Number of cache nodes",
    Validators: []validator.Int64{
        int64validator.AtLeast(1),
        int64validator.AtMost(10),
    },
},
"timeout": schema.Int64Attribute{
    Optional:    true,
    Description: "Connection timeout in seconds",
    Validators: []validator.Int64{
        int64validator.OneOf(30, 60, 90, 120),
    },
},

List Validators

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

"security_group_ids": schema.ListAttribute{
    ElementType: types.StringType,
    Optional:    true,
    Description: "Security group IDs",
    Validators: []validator.List{
        listvalidator.SizeAtLeast(1),
        listvalidator.SizeAtMost(5),
    },
},
"availability_zones": schema.ListAttribute{
    ElementType: types.StringType,
    Required:    true,
    Description: "Availability zones",
    Validators: []validator.List{
        listvalidator.SizeBetween(2, 3),
        listvalidator.UniqueValues(),
    },
},

Map Validators

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

"tags": schema.MapAttribute{
    ElementType: types.StringType,
    Optional:    true,
    Description: "Resource tags",
    Validators: []validator.Map{
        mapvalidator.SizeAtMost(50),
    },
},

Custom Validators

Create domain-specific validators for your provider.

Basic Custom Validator

package provider

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

type cacheEngineValidator struct{}

func (v cacheEngineValidator) Description(ctx context.Context) string {
    return "value must be a supported cache engine"
}

func (v cacheEngineValidator) MarkdownDescription(ctx context.Context) string {
    return "value must be a supported cache engine (`redis` or `memcached`)"
}

func (v cacheEngineValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    // Skip if value is unknown or null
    if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    validEngines := []string{"redis", "memcached"}
    
    for _, valid := range validEngines {
        if value == valid {
            return
        }
    }
    
    resp.Diagnostics.AddAttributeError(
        req.Path,
        "Invalid Cache Engine",
        fmt.Sprintf(
            "Engine must be one of %v, got: %s",
            validEngines,
            value,
        ),
    )
}

// Constructor function
func CacheEngine() validator.String {
    return cacheEngineValidator{}
}

Using Custom Validator

"engine": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        CacheEngine(),
    },
},

Validator with Parameters

type nodeTypeValidator struct {
    validTypes []string
}

func (v nodeTypeValidator) Description(ctx context.Context) string {
    return fmt.Sprintf("value must be one of: %v", v.validTypes)
}

func (v nodeTypeValidator) MarkdownDescription(ctx context.Context) string {
    return v.Description(ctx)
}

func (v nodeTypeValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    for _, valid := range v.validTypes {
        if value == valid {
            return
        }
    }
    
    resp.Diagnostics.AddAttributeError(
        req.Path,
        "Invalid Node Type",
        fmt.Sprintf(
            "Node type must be one of %v, got: %s. "+
            "See documentation for supported types: https://docs.example.com/node-types",
            v.validTypes,
            value,
        ),
    )
}

// Constructor with parameters
func NodeType(validTypes []string) validator.String {
    return nodeTypeValidator{
        validTypes: validTypes,
    }
}

// Usage
"node_type": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        NodeType([]string{
            "cache.t3.micro",
            "cache.t3.small",
            "cache.t3.medium",
            "cache.t3.large",
        }),
    },
},

Email Validator

type emailValidator struct{}

func (v emailValidator) Description(ctx context.Context) string {
    return "value must be a valid email address"
}

func (v emailValidator) MarkdownDescription(ctx context.Context) string {
    return "value must be a valid email address"
}

func (v emailValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    // Simple email regex (use more robust validation in production)
    emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
    
    if !emailRegex.MatchString(value) {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid Email Address",
            fmt.Sprintf("'%s' is not a valid email address", value),
        )
    }
}

func Email() validator.String {
    return emailValidator{}
}

URL Validator

type urlValidator struct {
    requireHTTPS bool
}

func (v urlValidator) Description(ctx context.Context) string {
    if v.requireHTTPS {
        return "value must be a valid HTTPS URL"
    }
    return "value must be a valid URL"
}

func (v urlValidator) MarkdownDescription(ctx context.Context) string {
    return v.Description(ctx)
}

func (v urlValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    parsedURL, err := url.Parse(value)
    if err != nil {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid URL",
            fmt.Sprintf("'%s' is not a valid URL: %s", value, err.Error()),
        )
        return
    }
    
    if parsedURL.Scheme == "" {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid URL",
            fmt.Sprintf("'%s' must include a scheme (http:// or https://)", value),
        )
        return
    }
    
    if v.requireHTTPS && parsedURL.Scheme != "https" {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "HTTPS Required",
            fmt.Sprintf("URL must use HTTPS, got: %s", parsedURL.Scheme),
        )
        return
    }
}

func URL(requireHTTPS bool) validator.String {
    return urlValidator{requireHTTPS: requireHTTPS}
}

// Usage
"webhook_url": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        URL(true), // Require HTTPS
    },
},

Combining Validators

Multiple validators can be applied to a single attribute:

"name": schema.StringAttribute{
    Required: true,
    Validators: []validator.String{
        // Length constraints
        stringvalidator.LengthBetween(3, 63),
        
        // Pattern matching
        stringvalidator.RegexMatches(
            regexp.MustCompile(`^[a-z][a-z0-9-]*[a-z0-9]$`),
            "must start with a letter, end with letter or number, contain only lowercase letters, numbers, and hyphens",
        ),
        
        // Custom validator
        NameNotReserved(),
    },
},

Reserved Words Validator

type reservedWordsValidator struct {
    reserved []string
}

func (v reservedWordsValidator) Description(ctx context.Context) string {
    return fmt.Sprintf("value must not be a reserved word: %v", v.reserved)
}

func (v reservedWordsValidator) MarkdownDescription(ctx context.Context) string {
    return v.Description(ctx)
}

func (v reservedWordsValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    for _, reserved := range v.reserved {
        if strings.EqualFold(value, reserved) {
            resp.Diagnostics.AddAttributeError(
                req.Path,
                "Reserved Word",
                fmt.Sprintf("'%s' is a reserved word and cannot be used", value),
            )
            return
        }
    }
}

func NameNotReserved() validator.String {
    return reservedWordsValidator{
        reserved: []string{
            "default",
            "system",
            "admin",
            "root",
            "internal",
        },
    }
}

Conditional Validators

Validators that depend on other attributes:

Conflicts With

"public_access": schema.BoolAttribute{
    Optional:    true,
    Description: "Enable public access",
    Validators: []validator.Bool{
        boolvalidator.ConflictsWith(path.MatchRoot("vpc_id")),
    },
},
"vpc_id": schema.StringAttribute{
    Optional:    true,
    Description: "VPC ID for private deployment",
},

Also Requires

"backup_enabled": schema.BoolAttribute{
    Optional: true,
    Validators: []validator.Bool{
        boolvalidator.AlsoRequires(
            path.MatchRoot("backup_retention_days"),
            path.MatchRoot("backup_window"),
        ),
    },
},

Exactly One Of

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

Testing Validators

Unit Test for Custom Validator

func TestCacheEngineValidator(t *testing.T) {
    tests := []struct {
        name      string
        value     types.String
        wantError bool
        errorMsg  string
    }{
        {
            name:      "valid redis",
            value:     types.StringValue("redis"),
            wantError: false,
        },
        {
            name:      "valid memcached",
            value:     types.StringValue("memcached"),
            wantError: false,
        },
        {
            name:      "invalid engine",
            value:     types.StringValue("mysql"),
            wantError: true,
            errorMsg:  "Invalid Cache Engine",
        },
        {
            name:      "null value",
            value:     types.StringNull(),
            wantError: false,
        },
        {
            name:      "unknown value",
            value:     types.StringUnknown(),
            wantError: false,
        },
        {
            name:      "empty string",
            value:     types.StringValue(""),
            wantError: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            req := validator.StringRequest{
                Path:        path.Root("engine"),
                ConfigValue: tt.value,
            }
            resp := &validator.StringResponse{}
            
            CacheEngine().ValidateString(context.Background(), req, resp)
            
            if tt.wantError {
                assert.True(t, resp.Diagnostics.HasError())
                if tt.errorMsg != "" {
                    assert.Contains(t, resp.Diagnostics[0].Summary(), tt.errorMsg)
                }
            } else {
                assert.False(t, resp.Diagnostics.HasError())
            }
        })
    }
}

Best Practices

1. Validate Early

// ✅ Good: Validate at schema level
"port": schema.Int64Attribute{
    Optional: true,
    Validators: []validator.Int64{
        int64validator.Between(1024, 65535),
    },
}

// ❌ Bad: Validate during API call
func (r *Resource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
    if port < 1024 || port > 65535 {
        resp.Diagnostics.AddError("Invalid Port", "...")
    }
}

2. Provide Clear Messages

// ✅ Good: Clear, actionable message
resp.Diagnostics.AddAttributeError(
    req.Path,
    "Invalid Node Type",
    fmt.Sprintf(
        "Node type '%s' is not supported. "+
        "Supported types: cache.t3.micro, cache.t3.small, cache.t3.medium. "+
        "See: https://docs.example.com/node-types",
        value,
    ),
)

// ❌ Bad: Vague message
resp.Diagnostics.AddAttributeError(
    req.Path,
    "Invalid",
    "Bad value",
)

3. Handle Null and Unknown

// ✅ Good: Skip validation for null/unknown
func (v myValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() {
        return
    }
    
    // Validation logic here
}

4. Reuse Validators

// ✅ Good: Create reusable validators
func ResourceName() validator.String {
    return stringvalidator.All(
        stringvalidator.LengthBetween(3, 63),
        stringvalidator.RegexMatches(
            regexp.MustCompile(`^[a-z][a-z0-9-]*$`),
            "must start with lowercase letter",
        ),
    )
}

// Use across multiple resources
"cache_name": schema.StringAttribute{
    Required:   true,
    Validators: []validator.String{ResourceName()},
},
"cluster_name": schema.StringAttribute{
    Required:   true,
    Validators: []validator.String{ResourceName()},
},

Key Takeaways

  • ✅ Use built-in validators for common scenarios
  • ✅ Create custom validators for domain-specific rules
  • ✅ Validate at schema level for early error detection
  • ✅ Provide clear, actionable error messages
  • ✅ Handle null and unknown values properly
  • ✅ Combine multiple validators for comprehensive validation
  • ✅ Use conditional validators for attribute relationships
  • ✅ Test validators with valid and invalid inputs
  • ✅ Make validators reusable across resources
  • ✅ Document validation rules in attribute descriptions

What’s Next?

Learn how to implement complex validation logic that requires multiple attributes.

Next: Plan-Time Validation →


Previous: ← Validation & Error Handling

Last updated: June 30, 2026

Updated: