Custom Validation (Advanced)

8 minute read

Advanced Validation Overview

Beyond built-in validators, custom validators enable complex business rules, cross-field validation, and API-based validation.

Custom Validator Interface

String Validator Template

package validators

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

type CustomStringValidator struct {
    // Configuration fields
}

func (v CustomStringValidator) Description(ctx context.Context) string {
    return "Description for users"
}

func (v CustomStringValidator) MarkdownDescription(ctx context.Context) string {
    return "Markdown description for documentation"
}

func (v CustomStringValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    // Skip if value is null or unknown
    if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    // Validation logic
    if !isValid(value) {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Validation Error",
            fmt.Sprintf("Value '%s' is invalid", value),
        )
    }
}

Cross-Attribute Validation

Object Validator

type backupConfigValidator struct{}

func ValidateBackupConfig() validator.Object {
    return backupConfigValidator{}
}

func (v backupConfigValidator) Description(ctx context.Context) string {
    return "Validates backup configuration consistency"
}

func (v backupConfigValidator) MarkdownDescription(ctx context.Context) string {
    return "Validates backup configuration consistency"
}

func (v backupConfigValidator) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) {
    // Get the object value
    var config struct {
        Enabled       types.Bool   `tfsdk:"enabled"`
        Schedule      types.String `tfsdk:"schedule"`
        RetentionDays types.Int64  `tfsdk:"retention_days"`
        Window        types.String `tfsdk:"window"`
    }
    
    resp.Diagnostics.Append(req.ConfigValue.As(ctx, &config, basetypes.ObjectAsOptions{})...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // If disabled, other fields should not be set
    if !config.Enabled.ValueBool() {
        if !config.Schedule.IsNull() {
            resp.Diagnostics.AddAttributeError(
                req.Path.AtName("schedule"),
                "Invalid Configuration",
                "Schedule should not be set when backup is disabled",
            )
        }
        if !config.RetentionDays.IsNull() {
            resp.Diagnostics.AddAttributeError(
                req.Path.AtName("retention_days"),
                "Invalid Configuration",
                "Retention days should not be set when backup is disabled",
            )
        }
        return
    }
    
    // If enabled, required fields must be set
    if config.Schedule.IsNull() {
        resp.Diagnostics.AddAttributeError(
            req.Path.AtName("schedule"),
            "Missing Required Field",
            "Schedule is required when backup is enabled",
        )
    }
    
    if config.RetentionDays.IsNull() {
        resp.Diagnostics.AddAttributeError(
            req.Path.AtName("retention_days"),
            "Missing Required Field",
            "Retention days is required when backup is enabled",
        )
    }
    
    // Validate retention is reasonable
    if !config.RetentionDays.IsNull() {
        retention := config.RetentionDays.ValueInt64()
        if retention < 1 || retention > 365 {
            resp.Diagnostics.AddAttributeError(
                req.Path.AtName("retention_days"),
                "Invalid Value",
                fmt.Sprintf("Retention days must be between 1 and 365, got %d", retention),
            )
        }
    }
    
    // Validate schedule format (cron expression)
    if !config.Schedule.IsNull() {
        schedule := config.Schedule.ValueString()
        if err := validateCronExpression(schedule); err != nil {
            resp.Diagnostics.AddAttributeError(
                req.Path.AtName("schedule"),
                "Invalid Cron Expression",
                fmt.Sprintf("Schedule '%s' is not a valid cron expression: %s", schedule, err),
            )
        }
    }
}

Usage

schema.SingleNestedAttribute{
    Description: "Backup configuration",
    Optional:    true,
    Validators: []validator.Object{
        ValidateBackupConfig(),
    },
    Attributes: map[string]schema.Attribute{
        "enabled": schema.BoolAttribute{
            Required: true,
        },
        "schedule": schema.StringAttribute{
            Optional: true,
        },
        "retention_days": schema.Int64Attribute{
            Optional: true,
        },
        "window": schema.StringAttribute{
            Optional: true,
        },
    },
}

Resource-Level Validation

ValidateConfig Method

func (r *CacheResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
    var config CacheResourceModel
    resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Validate node count and type combination
    if !config.NodeCount.IsNull() && !config.NodeType.IsNull() {
        nodeCount := config.NodeCount.ValueInt64()
        nodeType := config.NodeType.ValueString()
        
        // Check valid combinations
        if nodeCount > 1 && !strings.HasPrefix(nodeType, "cache.r") {
            resp.Diagnostics.AddError(
                "Invalid Configuration",
                fmt.Sprintf(
                    "Multi-node clusters (count=%d) require memory-optimized instance types (cache.r*), got %s",
                    nodeCount,
                    nodeType,
                ),
            )
        }
    }
    
    // Validate engine and engine_version compatibility
    if !config.Engine.IsNull() && !config.EngineVersion.IsNull() {
        engine := config.Engine.ValueString()
        version := config.EngineVersion.ValueString()
        
        if !isCompatibleVersion(engine, version) {
            resp.Diagnostics.AddError(
                "Incompatible Configuration",
                fmt.Sprintf(
                    "Engine '%s' does not support version '%s'",
                    engine,
                    version,
                ),
            )
        }
    }
    
    // Validate network configuration
    if config.NetworkConfig != nil {
        validateNetworkConfig(ctx, config.NetworkConfig, &resp.Diagnostics)
    }
}

func validateNetworkConfig(ctx context.Context, nc *NetworkConfigModel, diags *diag.Diagnostics) {
    if nc.VPCID.IsNull() {
        diags.AddError(
            "Missing Configuration",
            "VPC ID is required in network configuration",
        )
        return
    }
    
    // Subnet count validation
    var subnets []string
    nc.SubnetIDs.ElementsAs(ctx, &subnets, false)
    
    if len(subnets) < 2 {
        diags.AddWarning(
            "Suboptimal Configuration",
            "At least 2 subnets recommended for high availability",
        )
    }
    
    if len(subnets) > 6 {
        diags.AddError(
            "Invalid Configuration",
            fmt.Sprintf("Maximum 6 subnets allowed, got %d", len(subnets)),
        )
    }
}

API-Based Validation

Async Validation

type apiValidationValidator struct {
    client *Client
}

func ValidateWithAPI(client *Client) validator.String {
    return &apiValidationValidator{
        client: client,
    }
}

func (v *apiValidationValidator) Description(ctx context.Context) string {
    return "Validates value against API"
}

func (v *apiValidationValidator) MarkdownDescription(ctx context.Context) string {
    return "Validates value against API"
}

func (v *apiValidationValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    // Call API to validate
    valid, err := v.client.ValidateConfiguration(ctx, value)
    if err != nil {
        tflog.Warn(ctx, "API validation failed", map[string]interface{}{
            "error": err.Error(),
        })
        // Continue without API validation on error
        return
    }
    
    if !valid {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid Configuration",
            fmt.Sprintf("Value '%s' was rejected by the API", value),
        )
    }
}

Cached API Validation

type cachedAPIValidator struct {
    client *Client
    cache  *sync.Map
    ttl    time.Duration
}

type validationResult struct {
    Valid     bool
    ExpiresAt time.Time
}

func ValidateWithCachedAPI(client *Client, ttl time.Duration) validator.String {
    return &cachedAPIValidator{
        client: client,
        cache:  &sync.Map{},
        ttl:    ttl,
    }
}

func (v *cachedAPIValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    cacheKey := fmt.Sprintf("validate:%s", value)
    
    // Check cache
    if cached, found := v.cache.Load(cacheKey); found {
        result := cached.(*validationResult)
        if time.Now().Before(result.ExpiresAt) {
            if !result.Valid {
                resp.Diagnostics.AddAttributeError(
                    req.Path,
                    "Invalid Configuration",
                    fmt.Sprintf("Value '%s' is invalid (cached result)", value),
                )
            }
            return
        }
        v.cache.Delete(cacheKey)
    }
    
    // Validate via API
    valid, err := v.client.ValidateConfiguration(ctx, value)
    if err != nil {
        tflog.Warn(ctx, "API validation failed", map[string]interface{}{
            "error": err.Error(),
        })
        return
    }
    
    // Cache result
    v.cache.Store(cacheKey, &validationResult{
        Valid:     valid,
        ExpiresAt: time.Now().Add(v.ttl),
    })
    
    if !valid {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid Configuration",
            fmt.Sprintf("Value '%s' was rejected by the API", value),
        )
    }
}

Conditional Validation

Dependent Field Validation

type conditionalRequiredValidator struct {
    dependentPath  path.Expression
    dependentValue string
}

func ConditionallyRequired(dependentPath path.Expression, dependentValue string) validator.String {
    return &conditionalRequiredValidator{
        dependentPath:  dependentPath,
        dependentValue: dependentValue,
    }
}

func (v conditionalRequiredValidator) Description(ctx context.Context) string {
    return fmt.Sprintf("Required when %s equals '%s'", v.dependentPath, v.dependentValue)
}

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

func (v conditionalRequiredValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    // Get dependent field value
    var dependentValue types.String
    resp.Diagnostics.Append(req.Config.GetAttribute(ctx, v.dependentPath, &dependentValue)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Check if condition is met
    if dependentValue.ValueString() == v.dependentValue {
        // Condition met, this field is required
        if req.ConfigValue.IsNull() {
            resp.Diagnostics.AddAttributeError(
                req.Path,
                "Missing Required Field",
                fmt.Sprintf(
                    "This field is required when %s is '%s'",
                    v.dependentPath,
                    v.dependentValue,
                ),
            )
        }
    }
}

Usage

schema.StringAttribute{
    Description: "Engine version",
    Optional:    true,
    Validators: []validator.String{
        // Required when engine is "redis"
        ConditionallyRequired(
            path.MatchRoot("engine"),
            "redis",
        ),
    },
}

Complex Validation Patterns

Range with Conditions

type conditionalRangeValidator struct {
    min            int64
    max            int64
    conditionPath  path.Expression
    conditionValue string
}

func ConditionalRange(min, max int64, conditionPath path.Expression, conditionValue string) validator.Int64 {
    return &conditionalRangeValidator{
        min:            min,
        max:            max,
        conditionPath:  conditionPath,
        conditionValue: conditionValue,
    }
}

func (v conditionalRangeValidator) ValidateInt64(ctx context.Context, req validator.Int64Request, resp *validator.Int64Response) {
    if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
        return
    }
    
    // Check condition
    var conditionValue types.String
    resp.Diagnostics.Append(req.Config.GetAttribute(ctx, v.conditionPath, &conditionValue)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    if conditionValue.ValueString() != v.conditionValue {
        // Condition not met, skip validation
        return
    }
    
    // Validate range
    value := req.ConfigValue.ValueInt64()
    if value < v.min || value > v.max {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Invalid Value",
            fmt.Sprintf(
                "When %s is '%s', value must be between %d and %d, got %d",
                v.conditionPath,
                v.conditionValue,
                v.min,
                v.max,
                value,
            ),
        )
    }
}

List Element Validation

type uniqueListValidator struct{}

func UniqueListElements() validator.List {
    return uniqueListValidator{}
}

func (v uniqueListValidator) Description(ctx context.Context) string {
    return "Ensures all list elements are unique"
}

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

func (v uniqueListValidator) ValidateList(ctx context.Context, req validator.ListRequest, resp *validator.ListResponse) {
    if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
        return
    }
    
    // Get list elements
    var elements []attr.Value
    resp.Diagnostics.Append(req.ConfigValue.ElementsAs(ctx, &elements, false)...)
    if resp.Diagnostics.HasError() {
        return
    }
    
    // Check for duplicates
    seen := make(map[string]bool)
    for i, elem := range elements {
        strVal := elem.String()
        if seen[strVal] {
            resp.Diagnostics.AddAttributeError(
                req.Path.AtListIndex(i),
                "Duplicate Element",
                fmt.Sprintf("Element '%s' appears multiple times in list", strVal),
            )
        }
        seen[strVal] = true
    }
}

Validation Performance

Optimize Expensive Validations

type expensiveValidator struct {
    cache      *sync.Map
    cacheTTL   time.Duration
    rateLimiter *rate.Limiter
}

func NewExpensiveValidator(cacheTTL time.Duration, requestsPerSecond int) validator.String {
    return &expensiveValidator{
        cache:       &sync.Map{},
        cacheTTL:    cacheTTL,
        rateLimiter: rate.NewLimiter(rate.Limit(requestsPerSecond), 1),
    }
}

func (v *expensiveValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
    if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
        return
    }
    
    value := req.ConfigValue.ValueString()
    
    // Check cache
    if cached, found := v.cache.Load(value); found {
        result := cached.(*validationResult)
        if time.Now().Before(result.ExpiresAt) {
            if !result.Valid {
                resp.Diagnostics.AddAttributeError(
                    req.Path,
                    "Validation Failed",
                    "Value is invalid (cached)",
                )
            }
            return
        }
    }
    
    // Rate limit expensive operations
    if err := v.rateLimiter.Wait(ctx); err != nil {
        tflog.Warn(ctx, "Rate limit reached for validation")
        return
    }
    
    // Perform expensive validation
    valid := performExpensiveValidation(value)
    
    // Cache result
    v.cache.Store(value, &validationResult{
        Valid:     valid,
        ExpiresAt: time.Now().Add(v.cacheTTL),
    })
    
    if !valid {
        resp.Diagnostics.AddAttributeError(
            req.Path,
            "Validation Failed",
            fmt.Sprintf("Value '%s' failed validation", value),
        )
    }
}

Testing Custom Validators

Unit Tests

func TestBackupConfigValidator(t *testing.T) {
    tests := []struct {
        name      string
        config    map[string]attr.Value
        expectErr bool
    }{
        {
            name: "valid enabled config",
            config: map[string]attr.Value{
                "enabled":        types.BoolValue(true),
                "schedule":       types.StringValue("0 2 * * *"),
                "retention_days": types.Int64Value(7),
            },
            expectErr: false,
        },
        {
            name: "disabled with schedule",
            config: map[string]attr.Value{
                "enabled":  types.BoolValue(false),
                "schedule": types.StringValue("0 2 * * *"),
            },
            expectErr: true, // Schedule should not be set when disabled
        },
        {
            name: "enabled without schedule",
            config: map[string]attr.Value{
                "enabled":        types.BoolValue(true),
                "retention_days": types.Int64Value(7),
            },
            expectErr: true, // Schedule required when enabled
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            validator := ValidateBackupConfig()
            
            objType := types.ObjectType{
                AttrTypes: map[string]attr.Type{
                    "enabled":        types.BoolType,
                    "schedule":       types.StringType,
                    "retention_days": types.Int64Type,
                },
            }
            
            objVal, _ := types.ObjectValue(objType.AttrTypes, tt.config)
            
            req := validator.ObjectRequest{
                ConfigValue: objVal,
                Path:        path.Root("backup_config"),
            }
            
            resp := &validator.ObjectResponse{}
            
            validator.ValidateObject(context.Background(), req, resp)
            
            hasErr := resp.Diagnostics.HasError()
            if hasErr != tt.expectErr {
                t.Errorf("expected error: %v, got error: %v", tt.expectErr, hasErr)
            }
        })
    }
}

Best Practices

1. Clear Error Messages

// ✅ Good: Specific, actionable error
resp.Diagnostics.AddAttributeError(
    req.Path,
    "Invalid CIDR Block",
    fmt.Sprintf("CIDR block '%s' overlaps with existing block '10.0.0.0/16'. Choose a non-overlapping range.", value),
)

// ❌ Bad: Vague error
resp.Diagnostics.AddError("Validation Error", "Invalid value")

2. Skip Unknown Values

// ✅ Good: Check for unknown
if req.ConfigValue.IsUnknown() {
    return // Can't validate unknown value
}

// ❌ Bad: Attempt to validate unknown
value := req.ConfigValue.ValueString() // Panics on unknown

3. Performance Considerations

// ✅ Good: Cache expensive validations
if cached, found := v.cache.Load(value); found {
    return cached.(*validationResult)
}

// ❌ Bad: Repeat expensive operations
result := expensiveAPICall(value) // Called every validation

Key Takeaways

  • ✅ Implement custom validators for complex rules
  • ✅ Use cross-attribute validation for consistency
  • ✅ Validate at resource level when appropriate
  • ✅ Cache API-based validation results
  • ✅ Provide clear, actionable error messages
  • ✅ Skip validation for null/unknown values
  • ✅ Test validators thoroughly
  • ✅ Consider performance for expensive validations

What’s Next?

Learn provider middleware patterns for cross-cutting concerns.

Next: Provider Middleware →


Previous: ← Advanced State Management
Next: Provider Middleware →

Last updated: July 1, 2026

Updated: