Validation

In this tutorial, you’ll learn how to validate configuration values using Varlord’s built-in validators, required field validation, and custom validation logic.

Learning Objectives

By the end of this tutorial, you’ll be able to:

  • Use explicit required/optional field metadata

  • Validate required fields are provided

  • Use built-in validators to validate configuration values

  • Create custom validation logic

  • Understand when validation occurs

  • Handle validation errors

Step 1: Required/Optional Fields

Fields are automatically determined as required/optional:

 1from dataclasses import dataclass, field
 2from typing import Optional
 3from varlord import Config
 4
 5@dataclass(frozen=True)
 6class AppConfig:
 7    host: str = field(default="0.0.0.0")  # Optional (has default)
 8    port: int = field(default=8000)  # Optional (has default)
 9    api_key: str = field()  # Required (no default, not Optional)
10    timeout: Optional[int] = field()  # Optional (Optional type)
11
12# This will raise RequiredFieldError if api_key is not provided
13from varlord.model_validation import RequiredFieldError
14
15cfg = Config(
16    model=AppConfig,
17    sources=[],  # No sources provide api_key
18)
19
20try:
21    app = cfg.load()
22except RequiredFieldError as e:
23    print(f"Missing required fields: {e.missing_fields}")

Key Points:

  • Fields without defaults and not Optional[T] are required

  • Fields with Optional[T] type annotation are optional

  • Fields with defaults (or default_factory) are optional

  • Required fields must be provided by at least one source

  • Empty strings and empty collections are considered valid (presence is checked, not emptiness)

Step 2: Basic Value Validation

Let’s add validation to ensure configuration values are correct:

 1from dataclasses import dataclass, field
 2from varlord import Config
 3from varlord.validators import validate_port, validate_not_empty
 4
 5@dataclass(frozen=True)
 6class AppConfig:
 7    host: str = field(default="0.0.0.0")
 8    port: int = field(default=8000)
 9
10    def __post_init__(self):
11        validate_not_empty(self.host)
12        validate_port(self.port)
13
14cfg = Config(
15    model=AppConfig,
16    sources=[],
17)
18
19app = cfg.load()
20print(f"Configuration valid: {app.host}:{app.port}")

Expected Output:

Configuration valid: 0.0.0.0:8000

Key Points:

  • Validation happens in __post_init__

  • Validation occurs after all sources are merged

  • If validation fails, cfg.load() raises ValidationError

Step 3: Validation with Multiple Sources

Validation uses the final merged values, not just defaults:

 1import os
 2from dataclasses import dataclass, field
 3from varlord import Config, sources
 4from varlord.validators import validate_port, ValidationError
 5
 6@dataclass(frozen=True)
 7class AppConfig:
 8    port: int = field(default=8000)  # Valid default
 9
10    def __post_init__(self):
11        validate_port(self.port)
12
13# Set invalid port in environment
14os.environ["PORT"] = "70000"  # Invalid (too large)
15
16cfg = Config(
17    model=AppConfig,
18    sources=[
19        sources.Env(),
20    ],
21)
22
23try:
24    app = cfg.load()
25except ValidationError as e:
26    print(f"Validation failed: {e.message}")
27    print(f"  Key: {e.key}")
28    print(f"  Value: {e.value}")

Expected Output:

Validation failed: must be <= 65535
  Key: value
  Value: 70000

Important: Even though the default value (8000) is valid, validation fails because the final merged value (70000 from env) is invalid.

Step 4: Using Multiple Validators

You can use multiple validators for a single field:

 1from dataclasses import dataclass, field
 2from varlord import Config, sources
 3from varlord.validators import (
 4    validate_email,
 5    validate_url,
 6    validate_length,
 7    validate_not_empty,
 8)
 9
10@dataclass(frozen=True)
11class AppConfig:
12    admin_email: str = field(default="admin@example.com")
13    api_url: str = field(default="https://api.example.com")
14    api_key: str = field(default="")
15
16    def __post_init__(self):
17        validate_email(self.admin_email)
18        validate_url(self.api_url)
19        validate_not_empty(self.api_key)  # This will fail with empty default!
20        validate_length(self.api_key, min_length=32, max_length=64)
21
22cfg = Config(
23    model=AppConfig,
24    sources=[],
25)
26
27try:
28    app = cfg.load()
29except ValidationError as e:
30    print(f"Validation failed: {e.message}")

Expected Output:

Validation failed: must not be empty

Solution: Provide a valid default or ensure another source provides the value:

 1import os
 2
 3# Provide valid api_key from environment
 4os.environ["API_KEY"] = "a" * 32  # 32 characters
 5
 6cfg = Config(
 7    model=AppConfig,
 8    sources=[
 9        sources.Env(),
10    ],
11)
12
13app = cfg.load()
14print(f"API Key length: {len(app.api_key)}")  # 32

Expected Output:

API Key length: 32

Step 5: Validating Nested Configuration

Each nested dataclass can have its own validation:

 1from dataclasses import dataclass, field
 2from varlord import Config
 3from varlord.validators import validate_port, validate_not_empty
 4
 5@dataclass(frozen=True)
 6class DBConfig:
 7    host: str = field(default="localhost")
 8    port: int = field(default=5432)
 9
10    def __post_init__(self):
11        validate_not_empty(self.host)
12        validate_port(self.port)
13
14@dataclass(frozen=True)
15class AppConfig:
16    host: str = field(default="0.0.0.0")
17    port: int = field(default=8000)
18    db: DBConfig = field(default_factory=lambda: DBConfig())
19
20    def __post_init__(self):
21        validate_port(self.port)
22        # DBConfig's __post_init__ is automatically called
23        # No need to manually validate self.db
24
25cfg = Config(
26    model=AppConfig,
27    sources=[],
28)
29
30app = cfg.load()
31print(f"App port: {app.port}")
32print(f"DB port: {app.db.port}")

Expected Output:

App port: 8000
DB port: 5432

Key Points:

  • Nested objects are validated automatically when created

  • Parent’s __post_init__ is called after nested objects are validated

  • You can add cross-field validation in the parent’s __post_init__

Step 6: Cross-Field Validation

You can validate relationships between fields:

 1from dataclasses import dataclass, field
 2from varlord import Config
 3from varlord.validators import validate_port, ValidationError
 4
 5@dataclass(frozen=True)
 6class AppConfig:
 7    app_port: int = field(default=8000)
 8    db_port: int = field(default=8000)  # Same as app_port - will conflict!
 9
10    def __post_init__(self):
11        validate_port(self.app_port)
12        validate_port(self.db_port)
13
14        # Cross-field validation
15        if self.app_port == self.db_port:
16            raise ValidationError(
17                "app_port",
18                self.app_port,
19                f"App port conflicts with DB port {self.db_port}"
20            )
21
22# This will fail - ports conflict
23cfg = Config(
24    model=AppConfig,
25    sources=[],
26)
27
28try:
29    app = cfg.load()
30except ValidationError as e:
31    print(f"Validation failed: {e.message}")

Expected Output:

Validation failed: App port conflicts with DB port 8000

Step 7: Custom Validators

You can create custom validation functions:

 1from dataclasses import dataclass, field
 2from varlord import Config
 3from varlord.validators import ValidationError
 4
 5def validate_api_key_format(value: str) -> None:
 6    """Custom validator for API key format."""
 7    if not value.startswith("sk-"):
 8        raise ValidationError(
 9            "api_key",
10            value,
11            "API key must start with 'sk-'"
12        )
13    if len(value) < 32:
14        raise ValidationError(
15            "api_key",
16            value,
17            "API key must be at least 32 characters"
18        )
19
20@dataclass(frozen=True)
21class AppConfig:
22    api_key: str = field(default="")
23
24    def __post_init__(self):
25        validate_api_key_format(self.api_key)
26
27# This will fail with empty default
28cfg = Config(
29    model=AppConfig,
30    sources=[],
31)
32
33try:
34    app = cfg.load()
35except ValidationError as e:
36    print(f"Validation failed: {e.message}")

Expected Output:

Validation failed: API key must start with 'sk-'

Step 8: Complete Example

Here’s a complete example with comprehensive validation:

 1import os
 2from dataclasses import dataclass, field
 3from varlord import Config, sources
 4from varlord.validators import (
 5    validate_port,
 6    validate_email,
 7    validate_url,
 8    validate_length,
 9    validate_not_empty,
10    ValidationError,
11)
12
13@dataclass(frozen=True)
14class DBConfig:
15    host: str = field(default="localhost")
16    port: int = field(default=5432)
17
18    def __post_init__(self):
19        validate_not_empty(self.host)
20        validate_port(self.port)
21
22@dataclass(frozen=True)
23class AppConfig:
24    host: str = field(default="0.0.0.0")
25    port: int = field(default=8000)
26    admin_email: str = field(default="admin@example.com")
27    api_url: str = field(default="https://api.example.com")
28    api_key: str = field(default="")
29    db: DBConfig = field(default_factory=lambda: DBConfig())
30
31    def __post_init__(self):
32        validate_not_empty(self.host)
33        validate_port(self.port)
34        validate_email(self.admin_email)
35        validate_url(self.api_url)
36        validate_length(self.api_key, min_length=32, max_length=64)
37
38        # Cross-field validation
39        if self.port == self.db.port:
40            raise ValidationError(
41                "port",
42                self.port,
43                f"App port conflicts with DB port {self.db.port}"
44            )
45
46def main():
47    # Provide required values from environment
48    os.environ["API_KEY"] = "a" * 32
49
50    cfg = Config(
51        model=AppConfig,
52        sources=[
53            sources.Env(),
54        ],
55    )
56
57    try:
58        app = cfg.load()
59        print("Configuration validated successfully:")
60        print(f"  App: {app.host}:{app.port}")
61        print(f"  Admin: {app.admin_email}")
62        print(f"  API: {app.api_url}")
63        print(f"  API Key: {'*' * len(app.api_key)}")
64        print(f"  DB: {app.db.host}:{app.db.port}")
65    except ValidationError as e:
66        print(f"Validation failed: {e.message}")
67        print(f"  Key: {e.key}")
68        print(f"  Value: {e.value}")
69
70if __name__ == "__main__":
71    main()

Expected Output:

Configuration validated successfully:
  App: 0.0.0.0:8000
  Admin: admin@example.com
  API: https://api.example.com
  API Key: ********************************
  DB: localhost:5432

Common Pitfalls

Pitfall 1: Validating before sources are merged

@dataclass(frozen=True)
class AppConfig:
    api_key: str = ""

    def __post_init__(self):
        # This validates the FINAL merged value, not just the default
        validate_length(self.api_key, min_length=32)
        # If no source provides api_key, this will fail even if
        # you intended to provide it via environment

Solution: Remember that validation happens after all sources are merged. Ensure at least one source provides valid values, or use Optional for fields that may not always be set.

Pitfall 3: Not handling RequiredFieldError

@dataclass(frozen=True)
class AppConfig:
    api_key: str = field()

app = cfg.load()  # May raise RequiredFieldError if api_key not provided
print(app.api_key)  # This line won't execute if validation fails

Solution: Either provide the required field via a source, or use cfg.load(validate=False) to skip validation, or wrap in try-except to handle RequiredFieldError.

Pitfall 4: Not handling ValidationError

app = cfg.load()  # May raise ValidationError
print(app.port)  # This line won't execute if validation fails

Solution: Always wrap cfg.load() in try-except to handle validation errors gracefully.

Pitfall 5: Validating nested objects manually

@dataclass(frozen=True)
class AppConfig:
    db: DBConfig = field(default_factory=lambda: DBConfig())

    def __post_init__(self):
        # Unnecessary - DBConfig.__post_init__ is already called
        if self.db:
            validate_port(self.db.port)  # Redundant!

Solution: Nested objects are automatically validated. Only add validation in the parent if you need cross-field validation.

Best Practices

  1. Fields are automatically determined: Use Optional[T] type annotation or default values for optional fields

  2. Validate required fields: Use Config.validate() or cfg.load(validate=True) to ensure required fields are provided

  3. Validate field values: Use validators in __post_init__ to ensure data integrity

  4. Provide helpful error messages: Custom validators should explain what’s wrong

  5. Validate after merge: Remember validation uses final merged values

  6. Use built-in validators when possible: They’re tested and well-documented

  7. Add field descriptions: Use metadata={"description": "..."} for better documentation

Next Steps

Now that you understand validation, let’s learn about Dynamic Updates to handle configuration changes at runtime.