Custom Validation (Advanced)
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.
Previous: ← Advanced State Management
Next: Provider Middleware →
Last updated: July 1, 2026