Schema-Level Validation
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.
Previous: ← Validation & Error Handling
Last updated: June 30, 2026