Validation¶
dotenvmodel validates field values at load time, catching configuration errors before your application starts. All validation happens once during load() — there is zero runtime overhead when accessing fields afterwards.
Constraints are defined as parameters to Field(). This page covers every supported constraint with valid and invalid examples.
For the complete API reference, see Validation API.
Empty vs missing values
Optional[T] fields map missing and empty values to None and skip constraints, while plain str preserves empty strings as real values. So "validate only if present" is spelled str | None = Field(default=None, min_length=...).
Numeric Constraints¶
Numeric constraints apply to int, float, and Decimal fields.
| Parameter | Operator | Description |
|---|---|---|
ge |
>= |
Value must be greater than or equal |
le |
<= |
Value must be less than or equal |
gt |
> |
Value must be greater than |
lt |
< |
Value must be less than |
Examples¶
from decimal import Decimal
from dotenvmodel import DotEnvConfig, Field
class Config(DotEnvConfig):
# Greater than or equal (>=)
min_connections: int = Field(ge=1)
# Less than or equal (<=)
max_connections: int = Field(le=100)
# Greater than (>)
timeout: float = Field(gt=0)
# Less than (<)
percentage: float = Field(lt=100.0)
# Combined constraints
port: int = Field(default=8000, ge=1, le=65535)
pool_size: int = Field(default=10, ge=1, le=100)
# Works with Decimal too
tax_rate: Decimal = Field(ge=Decimal('0'), le=Decimal('1'))
Valid vs Invalid
Contradictory constraints
Field() raises ValueError at class definition time if you specify impossible constraints: ge > le, gt >= lt. This fails fast so you catch the bug immediately, not at runtime.
String Constraints¶
String constraints apply to str and SecretStr fields.
| Parameter | Description |
|---|---|
min_length |
Minimum string length (inclusive) |
max_length |
Maximum string length (inclusive) |
regex |
Regular expression pattern the string must match (uses re.match) |
starts_with |
Required string prefix |
ends_with |
Required string suffix |
Examples¶
from dotenvmodel import DotEnvConfig, Field
from dotenvmodel.types import SecretStr
class Config(DotEnvConfig):
# Minimum length
api_key: str = Field(min_length=32)
# Maximum length
username: str = Field(max_length=20)
# Regex pattern
email: str = Field(regex=r'^[\w\.-]+@[\w\.-]+\.\w+$')
# Required prefix / suffix
client_key: str = Field(starts_with="sk-")
signed_token: str = Field(ends_with=".sig")
# Combined constraints
password: str = Field(
min_length=8,
max_length=128,
regex=r'^(?=.*[A-Z])(?=.*[a-z])(?=.*\d).+$'
)
# SecretStr supports the same constraints
secret: SecretStr = Field(min_length=32)
Valid vs Invalid
Regex uses re.match
The regex constraint uses re.match, which anchors at the start of the string. Include ^ and $ in your pattern to anchor both ends, as shown in the examples above.
Use linear-time patterns (ReDoS)
regex and strip with an re.Pattern run developer-supplied patterns against env values, which can be operator-controlled. Avoid patterns with nested quantifiers (e.g. (a+)+, (a*)*) that can cause catastrophic backtracking (ReDoS). Prefer anchored, linear-time patterns.
Choice Validation¶
The choices parameter restricts a field to a list of allowed values. Validation happens after type coercion, so the coerced value must be in the list.
class Config(DotEnvConfig):
environment: str = Field(
default="dev",
choices=["dev", "test", "staging", "prod"],
)
log_level: str = Field(
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
)
Valid vs Invalid
Choices work with any type
choices validates after type coercion, so you can use it with int, bool, or any type. For example: port: int = Field(default=80, choices=[80, 443, 8080]).
Custom Validators¶
For logic the built-in constraints can't express, attach a validator hook. It receives the coerced, built-in-constraint-validated value plus a ValidatorContext (field name and resolved env var name), and its return value becomes the final field value — so a hook can also transform.
from dotenvmodel import DotEnvConfig, Field, SecretStr, ValidatorContext
def check_api_key(value: SecretStr, ctx: ValidatorContext) -> SecretStr:
# The hook receives the coerced value: a SecretStr stays wrapped,
# so call get_secret_value() to inspect the plaintext.
if not value.get_secret_value().startswith("sk-"):
# ValueError/TypeError are wrapped in ConstraintViolationError and
# aggregate into MultipleValidationErrors like any other failure
raise ValueError(f"{ctx.env_var_name} must start with 'sk-'")
return value
class Config(DotEnvConfig):
api_key: SecretStr = Field(validator=check_api_key)
# Transform example: normalize to lowercase
region: str = Field(default="us-east-1", validator=lambda v, ctx: v.lower())
Semantics:
- The hook receives the coerced value. A
SecretStrstays wrapped — useget_secret_value()to inspect the plaintext. - Runs after built-in constraints, on non-
Nonevalues only, and even withvalidate=False(transformation is part of loading, not validation). - Built-in constraints are not re-run on a transformed value (pydantic "after"-mode semantics).
- For sensitive fields (
SecretStr, DSN types), returning a plainstrre-wraps it in the declared type so the secret stays masked inrepr. - Returning
Noneon a non-Optionalfield raisesTypeCoercionError. - A
ValueErrororTypeErrorfrom the hook is wrapped inConstraintViolationErrorwithconstraint="validator=<fn name>". - Raise
ConstraintViolationErrordirectly for a fully custom message on non-sensitive fields (passed through untouched).
Sensitive-field leak prevention
For sensitive fields (SecretStr, DSN types), any exception from the hook is masked to a generic ConstraintViolationError with no exception chaining — the hook's exception text is never embedded in the error or its __cause__/__context__ chain, so a carelessly written hook cannot leak the secret or URL password into logs. For non-sensitive fields, str(e) is embedded in the error message and the original exception is chained.
Cross-Field Validation with post_load¶
Constraints and per-field validator hooks see one value at a time. For invariants that span several fields (lock_lease >= 4 * heartbeat_interval) or derived values built from multiple inputs (a replica DSN falling back to the primary), override the model-level post_load() hook. It runs once after all fields load cleanly, on every load path — load(), load_from_dict(), reload(), and nested config loading — and always runs, even with validate=False (transformation is part of loading, same as the per-field validator hook). The default implementation is a no-op.
from dotenvmodel import DotEnvConfig, Field, ValidationError
class WorkerConfig(DotEnvConfig):
primary_dsn: str = Field(default="postgresql://localhost/primary")
replica_dsn: str | None = Field(default=None)
heartbeat_interval: int = Field(default=5, ge=1) # seconds
lock_lease: int = Field(default=30, ge=1) # seconds
def post_load(self) -> list[ValidationError] | None:
# Fix / transform: derived value — fall back to the primary DSN.
if self.replica_dsn is None:
self.replica_dsn = self.primary_dsn
# Cross-validate: invariant spanning two fields.
if self.lock_lease < 4 * self.heartbeat_interval:
return [
ValidationError(
field_name="lock_lease", # tag the primary field
value=self.lock_lease,
error_msg="lock_lease must be >= 4 * heartbeat_interval",
)
]
return None
Valid vs Invalid
Usage modes (combinable in one body):
| Mode | How | Use for |
|---|---|---|
| Fix / transform | Mutate self, return None |
Derived values, fallbacks, normalization |
| Cross-validate | Return list[ValidationError] |
Invariants spanning multiple fields |
| Continue | Log or swallow issues internally, return None |
Non-fatal drift you only want to observe |
| Fatal | raise |
Unexpected/programming errors — an exception that is neither ValidationError nor MultipleValidationErrors propagates unchanged (never wrapped or aggregated), even from a nested hook |
Semantics:
- Return
Noneor[]→ success. One returned error → raised directly, its exact type preserved (e.g.ConstraintViolationError). Several → raised asMultipleValidationErrors. - Returned errors are the same
ValidationErrorobjects field validation produces, so introspection is uniform: iteratingMultipleValidationErrors.errorsyieldsfield_name,env_var_name,value, anderror_msgper error, whether the failure came from a constraint, a per-fieldvalidator, orpost_load. See Error Handling. - The hook runs only when every field loaded cleanly — cross-field checks always see coerced values; constraint validation has also been applied unless you loaded with
validate=False(which skips constraints but still runs the hook). A nested config's hook fires when the nested instance finishes loading, before the parent's hook; its returned errors flatten into the parent's collection. AValidationErrororMultipleValidationErrorsraised (rather than returned) by a nested hook is treated the same way — its errors join the parent's collection like nested field failures: a single collected error is re-raised as-is (a solo raisedMultipleValidationErrorssurfaces as its bare member), several aggregate intoMultipleValidationErrors. - Hook-author contract: tag each returned error with the primary field name (
lock_leaseabove) and reference the other participating fields inerror_msg.env_var_namedefaults to the uppercased field name if not passed.
Keep secrets out of error messages
The library redacts the value attribute for SecretStr/DSN fields when formatting raised errors, but it cannot mask prose you write. Never embed secret values in error_msg — and don't interpolate them into exceptions raised from the hook either, since those propagate unmasked.
When the hook does not run
post_load() does not run on bare Cls() construction (no load path is involved), and it does not run if any field fails to load. On reload() the hook re-runs against the freshly reloaded state; if it (or field validation) fails mid-reload, the instance may be partially reloaded — the same caveat as field errors during reload.
Pattern lineage
The hook follows pydantic's model_validator(mode="after") (run after all fields validate; mutate and return self) and the zod/t3-env final-schema .transform() pattern (one post-hook that can both transform values and accumulate multiple issues).
Collection Size Constraints¶
The min_items and max_items parameters constrain the number of items in list, set, tuple, and dict fields.
class Config(DotEnvConfig):
# At least one host required
allowed_hosts: list[str] = Field(
default_factory=list,
min_items=1,
)
# At most 10 IPs
allowed_ips: list[str] = Field(
default_factory=list,
max_items=10,
)
# Between 1 and 5 tags
tags: list[str] = Field(
default_factory=list,
min_items=1,
max_items=5,
separator=";",
)
Valid vs Invalid
UUID Version¶
The uuid_version parameter requires a UUID field to be a specific version (1, 3, 4, or 5).
from uuid import UUID
from dotenvmodel import DotEnvConfig, Field
class Config(DotEnvConfig):
# Must be a version 4 UUID
tenant_id: UUID = Field(uuid_version=4)
# Must be a version 1 UUID
request_id: UUID = Field(uuid_version=1)
Valid vs Invalid
Combining Constraints¶
You can combine multiple constraints on a single field. All constraints are checked, and the first violation raises a ConstraintViolationError.
class Config(DotEnvConfig):
# Numeric range + choices
port: int = Field(default=8000, ge=1, le=65535, choices=[80, 443, 8000, 8080])
# String length + regex + choices
env: str = Field(
default="dev",
min_length=2,
max_length=10,
choices=["dev", "test", "staging", "prod"],
)
# Collection size + custom separator
allowed_origins: list[str] = Field(
default_factory=list,
min_items=1,
max_items=10,
separator="|",
)
Error Handling¶
When validation fails, dotenvmodel raises a ConstraintViolationError (a subclass of ValidationError). If multiple fields fail simultaneously, all errors are collected and raised together as a MultipleValidationErrors exception.
from dotenvmodel import (
DotEnvConfig,
Field,
ConstraintViolationError,
MultipleValidationErrors,
)
class Config(DotEnvConfig):
port: int = Field(ge=1, le=65535)
host: str = Field(min_length=3)
try:
config = Config.load_from_dict({"PORT": "99999", "HOST": "ab"})
except ConstraintViolationError as e:
print(f"Field: {e.field_name}")
print(f"Constraint: {e.constraint}")
print(f"Error: {e.error_msg}")
except MultipleValidationErrors as e:
for error in e.errors:
print(f" - {error.field_name}: {error.error_msg}")
See the Error Handling guide for the full exception hierarchy and catching patterns.
See Also¶
- Field Definitions — all
Field()parameters - Supported Types — which types support which constraints
- Error Handling — exception hierarchy and error messages
- Validation API — auto-generated API reference